From 85ef2038cb86dcca48c46eec066797319cd1d3f6 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 9 Nov 2026 03:17:19 +0000 Subject: [PATCH] Read every setting through viper and rename the settings (closes #211) Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework) --- Dockerfile | 4 +- README.md | 385 ++++----- SPEC.md | 806 +++++++++--------- docs/admin-endpoints.md | 14 +- docs/alerts.md | 8 +- docs/metrics.md | 4 +- docs/request-log.md | 32 +- docs/settings.md | 490 +++++------ docs/state-files.md | 16 +- go.mod | 10 + go.sum | 24 + internal/alerts/alerts.go | 60 +- internal/alerts/alerts_test.go | 22 +- internal/anomaly/anomaly.go | 24 +- internal/bans/bans.go | 4 +- internal/config/config.go | 566 ++++++------ internal/config/config_test.go | 298 +++---- internal/config/configtest/configtest.go | 16 + internal/config/rproxy.go | 85 +- internal/config/rproxy_test.go | 65 +- internal/config/viper.go | 16 + internal/config/viper_test.go | 34 + internal/coreruleset/coreruleset.go | 22 +- internal/coreruleset/coreruleset_test.go | 6 +- internal/discriminator/abuseipdb.go | 12 +- internal/discriminator/admin.go | 2 +- internal/discriminator/admin_test.go | 8 +- internal/discriminator/alerts_test.go | 4 +- internal/discriminator/anomaly_test.go | 22 +- internal/discriminator/answertime_test.go | 8 +- internal/discriminator/bans.go | 12 +- internal/discriminator/bans_test.go | 22 +- internal/discriminator/biased.go | 35 +- internal/discriminator/biased_test.go | 14 +- internal/discriminator/blocklists.go | 6 +- internal/discriminator/bytelimits.go | 2 +- internal/discriminator/bytelimits_test.go | 16 +- internal/discriminator/client.go | 2 +- internal/discriminator/coreruleset.go | 31 +- internal/discriminator/coreruleset_test.go | 25 +- internal/discriminator/countries.go | 6 +- internal/discriminator/countries_test.go | 12 +- internal/discriminator/crowdsec.go | 2 +- internal/discriminator/crowdsec_test.go | 4 +- internal/discriminator/denynets.go | 4 +- internal/discriminator/discriminator.go | 6 +- internal/discriminator/discriminator_test.go | 6 +- internal/discriminator/dnsbl.go | 4 +- internal/discriminator/heuristics.go | 2 +- internal/discriminator/lookup.go | 4 +- internal/discriminator/lookup_test.go | 2 +- internal/discriminator/metrics_test.go | 16 +- internal/discriminator/observe_test.go | 6 +- internal/discriminator/proxy.go | 34 +- internal/discriminator/proxy_test.go | 93 +- internal/discriminator/ratelimits.go | 8 +- internal/discriminator/ratelimits_test.go | 8 +- internal/discriminator/refusallimit.go | 8 +- internal/discriminator/refusallimit_test.go | 10 +- internal/discriminator/reputation.go | 2 +- internal/discriminator/reputation_test.go | 32 +- internal/discriminator/request.go | 14 +- internal/discriminator/requestlog_test.go | 4 +- internal/discriminator/routewatchtags.go | 9 +- internal/discriminator/routewatchtags_test.go | 6 +- internal/discriminator/rulefiles.go | 2 +- internal/discriminator/rulefiles_test.go | 12 +- internal/discriminator/staticlists_test.go | 6 +- internal/discriminator/tagdeny.go | 4 +- internal/discriminator/tagdeny_test.go | 2 +- internal/discriminator/taglists.go | 4 +- internal/discriminator/taglists_test.go | 4 +- internal/discriminator/trappaths.go | 4 +- internal/discriminator/trappaths_test.go | 10 +- internal/lookup/file.go | 6 +- internal/lookup/file_internal_test.go | 6 +- internal/lookup/lookup.go | 4 +- internal/metrics/busiest.go | 2 +- internal/metrics/metrics.go | 20 +- internal/ratelimit/ratelimit.go | 4 +- internal/ratelimit/ratelimit_test.go | 2 +- internal/remotelog/remotelog.go | 16 +- internal/remotelog/remotelog_test.go | 2 +- internal/reputation/abuseipdb.go | 10 +- internal/reputation/dnsbl.go | 14 +- internal/reputation/dnsbl_test.go | 6 +- internal/reputation/reputation.go | 24 +- internal/reputation/reputation_test.go | 6 +- internal/requestlog/requestlog.go | 18 +- internal/routewatch/routewatch.go | 2 +- internal/rproxy/bodies.go | 8 +- internal/rproxy/discriminator.go | 10 +- internal/rproxy/limits_test.go | 11 +- internal/rproxy/request.go | 26 +- internal/rproxy/rproxy.go | 15 +- internal/rproxy/rproxy_test.go | 23 +- internal/rules/rules.go | 12 +- internal/rules/rules_test.go | 4 +- internal/state/state.go | 16 +- internal/state/state_test.go | 2 +- internal/sww/healthcheck.go | 28 +- internal/sww/healthcheck_test.go | 32 +- internal/sww/rproxy.go | 6 +- internal/sww/rproxy_test.go | 41 +- internal/sww/sww.go | 27 +- internal/sww/sww_test.go | 224 ++--- script/example-app | 32 +- script/run | 30 +- share/sww.run | 14 +- 109 files changed, 2201 insertions(+), 2084 deletions(-) create mode 100644 internal/config/configtest/configtest.go create mode 100644 internal/config/viper.go create mode 100644 internal/config/viper_test.go diff --git a/Dockerfile b/Dockerfile index b4d8690..8364eed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -184,12 +184,12 @@ RUN groupadd --system --gid 65532 sww \ --gid sww --no-create-home --shell /usr/sbin/nologin \ sww -# The state files' directory, SWW_STATE_DIR by default, where a volume +# The state files' directory, SWW_WAF_STATE_DIR by default, where a volume # is mounted to keep them across deploys. The run script gives it to the # sww user at each start of discriminator. RUN mkdir /var/lib/sww -# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile +# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile # built FROM this image can copy rule files of its own beside it. COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules diff --git a/README.md b/README.md index 8799b16..f0e50a1 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ 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. + app, and passes the app's answers back. It keeps nothing between requests and + writes nothing to 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 @@ -91,8 +91,9 @@ the app accepts connections, whether or not `discriminator` answers; in 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; +- `rproxy`, in front of the app, which traefik routes to: + `SWW_RPROXY_WAF_BACKEND` names `discriminator` and `SWW_RPROXY_UPSTREAM_URL` + names the app; - `discriminator`, with `SWW_COMMAND=discriminator` and a volume at `/var/lib/sww` for its state files. @@ -101,8 +102,8 @@ Point traefik's labels at port 8080 of `rproxy` 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`, whose `SWW_RPROXY_WAF_BACKEND` names the same `discriminator`. The +"Smallest setup" example below shows this shape. ### `rproxy` in the app's container @@ -115,7 +116,7 @@ by digest, and adds: - 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`. + of `SWW_RPROXY_UPSTREAM_URL`. ```dockerfile # The smallwebwaf image, pinned by digest. @@ -151,8 +152,8 @@ 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's image with `SWW_RPROXY_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 @@ -172,12 +173,12 @@ does. `https:///_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. + `SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app. `discriminator`'s log lines have `"would_action":"temporarily_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: +- With `SWW_WAF_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"). @@ -185,18 +186,18 @@ does. ## 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. +restarting the container. `rproxy` reads only the `SWW_RPROXY_` settings, and +`discriminator` only the `SWW_WAF_` ones. Every setting but +`SWW_RPROXY_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. +- Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for + secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds + `SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at + the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` + and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have + no `_FILE` form. `SWW_COMMAND` cannot be given as a file. - 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`. @@ -212,131 +213,132 @@ account, a key, a token or an alert destination stays off until you give one. setting takes it. - The byte limits and the byte anomaly thresholds count only body bytes: the app's response bodies, the visitors' request bodies, or both, as - `SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over - a WebSocket counts as request bytes, and what the app sent as response bytes. + `SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent + over a WebSocket counts as request bytes, and what the app sent as response + bytes. - 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`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | -| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | -| `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 are exempt from rate limiting and the byte limits. | -| `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`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | -| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | -| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | -| `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 client that breaks a limit again within this time after its 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` (a local database) 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_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | -| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | -| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | -| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | -| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | -| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | -| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | -| `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:` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). | -| `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:` 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:` 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 `. | -| `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,core_rule_set_match,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 more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | -| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client 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 more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | -| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client 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 more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | -| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number 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 more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | -| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service 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 more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | -| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. | +| Variable | Default | What it does | +| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | +| `SWW_RPROXY_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | +| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | +| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | +| `SWW_RPROXY_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. | +| `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | +| `SWW_RPROXY_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`. | +| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | +| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | +| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | +| `SWW_RPROXY_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_WAF_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | +| `SWW_WAF_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_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | +| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | +| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | +| `SWW_WAF_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | +| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | +| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. | +| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. | +| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. | +| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | +| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | +| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | +| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_WAF_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | +| `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | +| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | +| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | +| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. | +| `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | +| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | +| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | +| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. | +| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | +| `SWW_WAF_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_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | +| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | +| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | +| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | +| `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | +| `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | +| `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | +| `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | +| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | +| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS:` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). | +| `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | +| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. | +| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | +| `SWW_WAF_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | +| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | +| `SWW_WAF_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_WAF_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_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | +| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:` of every limit, or `log`. | +| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | +| `SWW_WAF_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_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | +| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | +| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | +| `SWW_WAF_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_WAF_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_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:` or `log`. | +| `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | +| `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | +| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | +| `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | +| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | +| `SWW_WAF_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_WAF_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_WAF_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_WAF_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_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | +| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | +| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | +| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | +| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. | +| `SWW_WAF_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_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | +| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer `. | +| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | +| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | +| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | +| `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | +| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | +| `SWW_WAF_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_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | +| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | +| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | +| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. | +| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | +| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | +| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | +| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. | +| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. | +| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. | +| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | +| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | +| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | +| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. | +| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | +| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | +| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | +| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. | +| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | +| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | +| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | +| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | +| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock 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. @@ -363,8 +365,8 @@ services: rproxy: image: smallwebwaf environment: - SWRP_WAF_BACKEND: http://discriminator:8080 - SWRP_UPSTREAM_URL: http://app:8081 + SWW_RPROXY_WAF_BACKEND: http://discriminator:8080 + SWW_RPROXY_UPSTREAM_URL: http://app:8081 ports: - "8080:8080" @@ -399,8 +401,8 @@ services: rproxy: image: smallwebwaf environment: - SWRP_WAF_BACKEND: http://discriminator:8080 - SWRP_UPSTREAM_URL: http://app:8081 + SWW_RPROXY_WAF_BACKEND: http://discriminator:8080 + SWW_RPROXY_UPSTREAM_URL: http://app:8081 ports: - "8080:8080" @@ -409,22 +411,22 @@ services: environment: SWW_COMMAND: discriminator # Monitoring skips every check. - SWW_ALLOW_NETS: 192.0.2.10 + SWW_WAF_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 + SWW_WAF_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 + SWW_WAF_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" + SWW_WAF_RATE_LIMIT_PER_MINUTE: "60" + SWW_WAF_RATE_LIMIT_PER_HOUR: "600" + SWW_WAF_RATE_LIMIT_PER_DAY: "3000" # Hosting networks and one country get a share of those limits. - SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25 + SWW_WAF_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25 # Paths only scanners ask for; one request bans the client. - SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php + SWW_WAF_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 + SWW_WAF_BAN_RESPONSE: "429" + SWW_WAF_LIMIT_BAN_DURATION: 30m volumes: - discriminator-state:/var/lib/sww @@ -450,9 +452,9 @@ services: # A Dockerfile built FROM the smallwebwaf image, with rproxy inside. build: ./app environment: - SWRP_WAF_BACKEND: http://discriminator:8080 + SWW_RPROXY_WAF_BACKEND: http://discriminator:8080 # Answer 503 rather than pass a request on while discriminator is down. - SWRP_DEFAULT_DISPOSITION: deny + SWW_RPROXY_DEFAULT_DISPOSITION: deny ports: - "8080:8080" @@ -460,16 +462,16 @@ services: image: smallwebwaf environment: SWW_COMMAND: discriminator - SWW_INSTANCE_NAME: fsn1/discriminator + SWW_WAF_INSTANCE_NAME: fsn1/discriminator # Refuse nothing yet; log what enforce mode would have done. - SWW_MODE: observe + SWW_WAF_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 + SWW_WAF_LOOKUP_SOURCE: file + SWW_WAF_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,core_rule_set_match,source_failure,file_error + SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf + SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy + SWW_WAF_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error volumes: - discriminator-state:/var/lib/sww - /srv/ipinfo:/var/lib/ipinfo:ro @@ -506,18 +508,18 @@ of the request reaches the app. 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. + from `SWW_RPROXY_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 tag lists. -4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. +4. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`. 5. The bans. 6. The lookup of the client's AS number and country. 7. The country list. 8. The reputation sources. -9. The tag refusals of `SWW_TAG_DENY`. +9. The tag refusals of `SWW_WAF_TAG_DENY`. 10. The client's limit percentage, from its tags, its AS number, its country or a reputation hit. 11. The rate limits. @@ -533,23 +535,24 @@ of the request reaches the app. 17. The request is passed to the app, streaming. 18. After the answer, `rproxy` sends `discriminator` a report with the status and the body bytes each way. For an allowed request, `discriminator` counts - the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if - they take it past one, and adds the request to the client's history. + the bytes `SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the + client if they take it past one, 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 14 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. +A refused visitor is answered with `SWW_WAF_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_WAF_ERROR_BURST_THRESHOLD` +instead. A request over the header limits of step 14 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. +- A client is one IPv4 address or one IPv6 netblock of + `SWW_WAF_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. + (`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_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. @@ -564,30 +567,30 @@ Each request gets tags from what `discriminator` knows of its client, written in the `tags` field of its log line: - `asn=` and `country=`, from the lookup; -- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit` - or `vpn=mullvad`; +- one tag per list of `SWW_WAF_TAG_LISTS` that holds the client, such as + `torexit` or `vpn=mullvad`; - `crowdsec`, `dnsbl=` and `abuseipdb`, when that reputation source lists the client; - `exploitsignature`, when a rule file or the Core Rule Set matched the request; - `asn=` and `prefix=`, from a routewatch instance, while - `SWW_ROUTEWATCH_URL` is set. + `SWW_WAF_ROUTEWATCH_URL` is set. -`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a -client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). +`SWW_WAF_TAG_DENY` refuses requests by tag, and `SWW_WAF_TAG_LIMIT_PERCENT` +lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.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. + headers of every request, and no body unless + `SWW_WAF_CORE_RULE_SET_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. +- `SWW_WAF_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)). @@ -595,7 +598,7 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.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`, +`SWW_WAF_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 diff --git a/SPEC.md b/SPEC.md index e3b5123..18774a7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -30,14 +30,14 @@ to them. and netblocks, lower its limits for listed AS numbers and countries, consult reputation sources, and send alerts. - Everything is set by environment variables. The names of `discriminator`'s - start with `SWW_`, and those of `rproxy` with `SWRP_`. The only other inputs - are `discriminator`'s files: - - the rule files; - - the state files; - - a lookup database; - - a `SWW_` setting given as a file; - - the file `SWW_LOG_REMOTE_TLS_CA_FILE` names; - - the file `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names. + start with `SWW_WAF_`, and those of `rproxy` with `SWW_RPROXY_`. The only + other inputs are files: + - `discriminator`'s rule files; + - `discriminator`'s state files; + - `discriminator`'s lookup database; + - a `SWW_WAF_` or `SWW_RPROXY_` setting given as a file; + - the file `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` names; + - the file `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` names. ## Non-goals @@ -108,9 +108,9 @@ to them. - `github.com/corazawaf/coraza/v3` and `github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries the Core Rule Set 4.25.0. The changes `smallwebwaf` makes to it, and the - default `SWW_WAF_DISABLED_RULES`, are built for that version. They are - checked again before the version changes, since rule ids, and what each - rule matches, change between versions; + default `SWW_WAF_CORE_RULE_SET_DISABLED_RULES`, are built for that + version. They are checked again before the version changes, since rule + ids, and what each rule matches, change between versions; - `github.com/hashicorp/golang-lru/v2` for the bounded tables; - `github.com/oschwald/maxminddb-golang/v2` to read the lookup database; - `github.com/fsnotify/fsnotify` to notice edited files; @@ -133,47 +133,48 @@ request log's `heuristic` field gives that name. `refusal_limit` and 1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a health checker never needs an exemption and is never refused. 2. The client is identified. - - If the TCP peer is inside `SWRP_TRUSTED_PROXIES`, `X-Forwarded-For` is - walked from the right, and the first address outside those netblocks is + - If the TCP peer is inside `SWW_RPROXY_TRUSTED_PROXIES`, `X-Forwarded-For` + is walked from the right, and the first address outside those netblocks is the client. If every address is inside them, the leftmost is, so that a visitor on a private network is known by its own address. An entry that is not an address ends the walk. With no header, the peer is the client. - - If the peer is outside `SWRP_TRUSTED_PROXIES`, it is the client, and the - header is ignored. + - If the peer is outside `SWW_RPROXY_TRUSTED_PROXIES`, it is the client, and + the header is ignored. - `rproxy` then sends `discriminator` the question (see "The question"). `discriminator` takes the address the question gives as the client. - - An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX` + - An IPv6 client is its group: the netblock of `SWW_WAF_IPV6_GROUP_PREFIX` around its address, a /64 by default, since one abuser usually controls a whole /64. A client is therefore one IPv4 address or one IPv6 group. 3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and - `prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in - `SWW_ALLOW_NETS` gets no tag. -4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte - limits and the error burst. A client in `SWW_DENY_NETS` is refused + `prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. A client in + `SWW_WAF_ALLOW_NETS` gets no tag. +4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the + byte limits and the error burst. A client in `SWW_WAF_DENY_NETS` is refused (`deny_nets`); a client in both lists is let through. 5. Bans. A client under a ban is refused (`active_ban`; see "Bans"). -6. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and - country"). The request goes on at once. It waits for the first GeoJS answer, - up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it: +6. The lookup, unless `SWW_WAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number + and country"). The request goes on at once. It waits for the first GeoJS + answer, up to `SWW_WAF_LOOKUP_TIMEOUT`, only when one of these needs it: - the country list; - a biased threshold; - - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=` - tag; - - `SWW_ADD_LOOKUP_HEADERS`. + - `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or + `country=` tag; + - `SWW_WAF_ADD_LOOKUP_HEADERS`. A request that ends before the answer comes is logged without it. 7. The country list (`countries`; see "Country list"). 8. The reputation sources (see "Reputation"), in this order: - - A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION` + - A client on a fetched blocklist gets `SWW_WAF_BLOCKLIST_ACTION` (`blocklists`). - A client on the CrowdSec decision list is banned (`crowdsec`). - - A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`). - - A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets - `SWW_REPUTATION_ACTION` (`abuseipdb`). + - A client listed by a DNS blocklist gets `SWW_WAF_REPUTATION_ACTION` + (`dnsbl`). + - A client scored at least `SWW_WAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets + `SWW_WAF_REPUTATION_ACTION` (`abuseipdb`). 9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag - in `SWW_TAG_DENY` is refused. + in `SWW_WAF_TAG_DENY` is refused. 10. The client's limit percentage (see "Biased thresholds"). 11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A request over a limit times the percentage is refused and bans the client. @@ -183,22 +184,22 @@ request log's `heuristic` field gives that name. `refusal_limit` and rule that refuses. 14. The header limits (`header_limits`; see "Answering in time"), and then the Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set - match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged - and alerted while it is `detect`. + match is refused with `403` while `SWW_WAF_CORE_RULE_SET_MODE` is `block`, + and only logged and alerted while it is `detect`. 15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse too. 16. A path under `/_sww/` is answered `404`, so that the app never sees that prefix. -17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing - a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and - `X-Client-Country` are added for the app's own logs; a header whose value is - unknown is left out. Whatever that setting says, any such header the client - sent is removed. +17. `rproxy` passes the request to `SWW_RPROXY_UPSTREAM_URL`, streaming (see + "Passing a request to the app"). With `SWW_WAF_ADD_LOOKUP_HEADERS`, + `X-Client-ASN` and `X-Client-Country` are added for the app's own logs; a + header whose value is unknown is left out. Whatever that setting says, any + such header the client sent is removed. 18. After the visitor's answer has ended, `rproxy` sends the report (see "The report"). `discriminator` then counts: - For a request it allowed, the body bytes of the response, of the request - or of both are added to the byte counters, as `SWW_BYTES_COUNT` says. For - a WebSocket, what it carried each way counts once it closes. A client + or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNT` says. + For a WebSocket, what it carried each way counts once it closes. A client whose total passes a byte limit times the percentage has broken that limit and is banned (`byte_limits`). The request that broke it has already been passed on whole; nothing is cut off. @@ -210,7 +211,7 @@ request log's `heuristic` field gives that name. `refusal_limit` and the refusals the error burst counts at the decision too (`refusal_limit`; see "Attack detection"). -In `SWW_MODE=observe`, every refusal above is logged as what would have +In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have happened, nothing is refused, and `discriminator` makes no ban of its own. A path under `/_sww/` is still answered `404`, and `rproxy`'s size and time limits still apply. @@ -225,21 +226,22 @@ still apply. and loaded again at start (see "Persistent state"), so a restart does not hand every client a fresh allowance. Buckets whose time has passed are discarded on load. -- Memory is bounded. When the table of clients holds `SWW_MAX_TRACKED_CLIENTS`, - the least recently seen client is dropped first, with its history. The bans - are bounded by `SWW_MAX_BANS` (see "Bans"). The GeoJS answers, the reputation - verdicts and scores, the DNS queries under way and the anomaly counters have - fixed bounds, listed under "Fixed limits" in `docs/settings.md`. Past its - bound, the GeoJS answer or anomaly counter used longest ago goes first, and - the verdict or score fetched longest ago. Past the bound on DNS queries under - way, a zone is asked at the client's next request. +- Memory is bounded. When the table of clients holds + `SWW_WAF_MAX_TRACKED_CLIENTS`, the least recently seen client is dropped + first, with its history. The bans are bounded by `SWW_WAF_MAX_BANS` (see + "Bans"). The GeoJS answers, the reputation verdicts and scores, the DNS + queries under way and the anomaly counters have fixed bounds, listed under + "Fixed limits" in `docs/settings.md`. Past its bound, the GeoJS answer or + anomaly counter used longest ago goes first, and the verdict or score fetched + longest ago. Past the bound on DNS queries under way, a zone is asked at the + client's next request. ## Bans Until a ban ends, every request from its netblock is refused with -`SWW_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to -`SWW_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one -address or one /64, so a permanent ban does not reach neighbours who did +`SWW_WAF_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to +`SWW_WAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers +one address or one /64, so a permanent ban does not reach neighbours who did nothing. Requests refused under a ban are not counted against limits. They are counted in the client's history and in the ban's notes. @@ -253,9 +255,9 @@ Offences are counted by kind in the client's history. The first two kinds lead to a ban, each by its own rule: - A clear sign of attack: a request that matches a rule whose action is `ban`, - or asks for a path in `SWW_TRAP_PATHS`. Examples are a probe for a `.env` file - or a `.git` directory, and a known scanner's user agent. - - The first one bans the netblock for `SWW_ATTACK_BAN_DURATION` (default + or asks for a path in `SWW_WAF_TRAP_PATHS`. Examples are a probe for a `.env` + file or a `.git` directory, and a known scanner's user agent. + - The first one bans the netblock for `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`), with the cause `attack`. - Any further request from the netblock while that ban lasts shows it is malicious: the ban becomes permanent. No other cause of ban becomes @@ -266,17 +268,17 @@ to a ban, each by its own rule: - a request over a rate limit; - the bytes counted for a request and its response taking the client's total past a byte limit; - - more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute. + - more than `SWW_WAF_ERROR_BURST_THRESHOLD` refusals within a minute. The bans for a broken limit: - - The first such ban lasts `SWW_LIMIT_BAN_DURATION` (default `1h`), with the - cause `limit`. So does one that comes more than - `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban + - The first such ban lasts `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`), with + the cause `limit`. So does one that comes more than + `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban ended. - Breaking a limit again within that window makes the new ban three times as long as the last one: one hour, then 3, 9, 27 and 81 hours. - - A ban that would be longer than `SWW_MAX_BAN_DURATION` (default `7d`) is - permanent instead. At the defaults, that is the sixth ban in a row. + - A ban that would be longer than `SWW_WAF_MAX_BAN_DURATION` (default `7d`) + is permanent instead. At the defaults, that is the sixth ban in a row. - Each such ban sets the client's counters for every limit back to zero. Once it ends, only new requests can break a limit again. The client's history keeps its totals. @@ -297,10 +299,10 @@ fetched again. These refuse each request they cover, record nothing in `bans.json`, and count no offence: -- `SWW_DENY_NETS`; +- `SWW_WAF_DENY_NETS`; - a blocklist or reputation hit whose action is `deny`; - the country list; -- `SWW_TAG_DENY`. +- `SWW_WAF_TAG_DENY`. Every ban carries notes with what an admin needs to decide whether to lift it, drawn from what `discriminator` already knows. No lookup is made for the notes @@ -315,11 +317,12 @@ entry from `bans.json` forgets it entirely. A permanent ban does not run out. It ends when an admin lifts it, or when `discriminator` drops it from a full table, as below. When `discriminator` holds -`SWW_MAX_BANS` bans of its own, it drops the earliest ban of one netblock: the -netblock that has gone longest without a request. A past, active or permanent -ban is dropped alike. A scanner whose ban was dropped that way is refused and -banned again by its next probe. A ban whose cause is `admin` is never dropped -and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table. +`SWW_WAF_MAX_BANS` bans of its own, it drops the earliest ban of one netblock: +the netblock that has gone longest without a request. A past, active or +permanent ban is dropped alike. A scanner whose ban was dropped that way is +refused and banned again by its next probe. A ban whose cause is `admin` is +never dropped and does not count toward `SWW_WAF_MAX_BANS`, so such bans can +never fill the table. A request refused under a ban that runs out is logged with the action `temporarily_banned`. Under a permanent ban the action is `permanently_banned`. @@ -336,26 +339,26 @@ below are `discriminator`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions: - These settings take `off`, which switches them off: - - `SWW_LOOKUP_SOURCE` and `SWW_WAF_MODE`; + - `SWW_WAF_LOOKUP_SOURCE` and `SWW_WAF_CORE_RULE_SET_MODE`; - the three rate limits and the three byte limits; - - `SWW_WAF_ANOMALY_THRESHOLD`, `SWW_WAF_BODY_LIMIT` and - `SWW_ERROR_BURST_THRESHOLD`; - - `SWW_ALERT_COOLDOWN` and `SWW_ALERT_MAX_PER_HOUR`; + - `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`, + `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` and `SWW_WAF_ERROR_BURST_THRESHOLD`; + - `SWW_WAF_ALERT_COOLDOWN` and `SWW_WAF_ALERT_MAX_PER_HOUR`; - every anomaly threshold; - - `SWRP_MAX_REQUEST_SIZE`, `SWRP_MAX_RESPONSE_SIZE`, - `SWRP_MAX_REQUEST_TIME_MS` and `SWRP_MAX_RESPONSE_TIME_MS`. + - `SWW_RPROXY_MAX_REQUEST_SIZE`, `SWW_RPROXY_MAX_RESPONSE_SIZE`, + `SWW_RPROXY_MAX_REQUEST_TIME_MS` and `SWW_RPROXY_MAX_RESPONSE_TIME_MS`. No other setting can be switched off with `off`. - A list set to an empty value is an empty list, and replaces the default. -- Any `SWW_` setting can be read from 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, and are checked as - the value itself would be. Setting both forms stops the start, and so does - naming a file that cannot be read. `SWW_LOG_REMOTE_TLS_CA_FILE` and - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already hold a file name, so they have no - `_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has - one, since `rproxy` never touches disk. +- Any `SWW_WAF_` or `SWW_RPROXY_` setting can be read from a file instead, for + secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds + `SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at + the end, and are checked as the value itself would be. Setting both forms + stops the start, and so does naming a file that cannot be read. + `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` + already hold a file name, so they have no `_FILE` form. `SWW_COMMAND` cannot + be given as a file. - Settings, files included, are read once at start. Changing one means restarting the container. The files `discriminator` watches while it runs are its state files, its rule files and the lookup database. @@ -368,59 +371,61 @@ Conventions: ### Core -- `SWW_INSTANCE_NAME` is in each line `discriminator` logs, and in each metric - and alert, so that instances can be told apart. The default, the container's - host name, changes at every redeploy; a name set by the operator survives it. -- `SWW_MODE=observe` lets a configuration be tried before it is enforced (see - "What happens to a request"). -- `SWW_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6 client - starts afresh; existing bans keep refusing their netblocks until they end. - `SWW_MAX_TRACKED_CLIENTS` and `SWW_MAX_BANS` set the bounds under "Counting - method" and "Bans". +- `SWW_WAF_INSTANCE_NAME` is in each line `discriminator` logs, and in each + metric and alert, so that instances can be told apart. The default, the + container's host name, changes at every redeploy; a name set by the operator + survives it. +- `SWW_WAF_MODE=observe` lets a configuration be tried before it is enforced + (see "What happens to a request"). +- `SWW_WAF_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6 + client starts afresh; existing bans keep refusing their netblocks until they + end. `SWW_WAF_MAX_TRACKED_CLIENTS` and `SWW_WAF_MAX_BANS` set the bounds under + "Counting method" and "Bans". ### Tokens -- `SWW_ADMIN_TOKEN` switches on the ban endpoints and `/_sww/clients/`. The - metrics are off until `SWW_METRICS_TOKEN` is set, since without a token they - would be open to anything on `discriminator`'s network. The two are separate, - so the metrics collector, which holds the metrics token, cannot manage bans. +- `SWW_WAF_ADMIN_TOKEN` switches on the ban endpoints and `/_sww/clients/`. + The metrics are off until `SWW_WAF_METRICS_TOKEN` is set, since without a + token they would be open to anything on `discriminator`'s network. The two are + separate, so the metrics collector, which holds the metrics token, cannot + manage bans. - A token is a long random value. One shorter than 32 characters stops the start. The file form keeps a token out of the container's environment (see "Tokens" under "Deployment"). ### State files -- `SWW_STATE_DIR` names the directory of the state files (see "State" under +- `SWW_WAF_STATE_DIR` names the directory of the state files (see "State" under "Deployment"). -- `bans.json` is written `SWW_STATE_WRITE_DELAY` after a ban is made, lifted or - made permanent, so a burst of changes becomes one write. Every file is written - every `SWW_STATE_COUNTER_INTERVAL`, and at an orderly stop. +- `bans.json` is written `SWW_WAF_STATE_WRITE_DELAY` after a ban is made, lifted + or made permanent, so a burst of changes becomes one write. Every file is + written every `SWW_WAF_STATE_COUNTER_INTERVAL`, and at an orderly stop. ### Logging - The request log on stdout is always on and has no switch (see "Request log"). - `SWW_LOG_LEVEL` filters only the process's own messages, and no message is at - `debug`. `warn` and `error` hold back the line of settings at start. -- `SWW_LOG_REQUEST_HEADERS` names the request headers to record. + `SWW_WAF_LOG_LEVEL` filters only the process's own messages, and no message is + at `debug`. `warn` and `error` hold back the line of settings at start. +- `SWW_WAF_LOG_REQUEST_HEADERS` names the request headers to record. `Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if listed. The log says only whether `Authorization` and `Cookie` were present. -- `SWW_LOG_REMOTE_URL` names a syslog server (see "Request log"). +- `SWW_WAF_LOG_REMOTE_URL` names a syslog server (see "Request log"). ### Metrics -- `SWW_METRICS_TOP_N` is how many of the busiest AS numbers and countries get a - series of their own. The rest are summed as `other`, so the label set stays - bounded. +- `SWW_WAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries + get a series of their own. The rest are summed as `other`, so the label set + stays bounded. ### Static lists -- `SWW_ALLOW_NETS` skips the checks step 4 names, for monitoring or the owner's - own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte - limits only; the error burst, attack detection and bans still apply. +- `SWW_WAF_ALLOW_NETS` skips the checks step 4 names, for monitoring or the + owner's own networks. `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request + and byte limits only; the error burst, attack detection and bans still apply. - Many people can share one address, as in an office, on a mobile carrier or through Tor. They are one client, and share its limits and its bans. A known - shared address busier than the limits allow belongs in `SWW_ALLOW_NETS` or - `SWW_RATE_LIMIT_EXEMPT_NETS`. + shared address busier than the limits allow belongs in `SWW_WAF_ALLOW_NETS` or + `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. ### Request rate limits, per client @@ -448,28 +453,29 @@ Some legitimate clients pass the limits: - Everyone behind one shared address, such as an office, shares one client's limits. -Their addresses belong in `SWW_RATE_LIMIT_EXEMPT_NETS`. +Their addresses belong in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. -`SWW_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as +`SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as static assets or health checks; their bytes are not counted either. A prefix is matched exactly as sent, without the query, and is not percent-decoded: `/assets/` matches `/assets/app.js`, and does not match `/Assets/app.js`, `/%61ssets/app.js` or `/static/assets/app.js`. There are no wildcards. A path holding `..`, a backslash or an encoded slash (`%2F`) is never exempt, since the -app may read it as a path outside the prefix. `SWW_WAF_EXEMPT_PATHS` is matched -the same way. +app may read it as a path outside the prefix. +`SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` is matched the same way. ### Byte limits, per client The byte limits count body bytes: the app's response bodies, the visitors' -request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted. -What the visitor sent over a WebSocket counts as request bytes, and what the app -sent as response bytes. The bytes counted for a request and its response are -added to the client's total once the response has ended (see step 18). So the -request that takes a client past a limit is passed on whole, and the ban refuses -the client's requests from then on. By default there is no minute limit, so a -client can download in a burst at full speed. The hour limit defaults to 50 GiB -and the day limit to 100 GiB. They allow several downloads of a few GiB each. +request bodies, or both, as `SWW_WAF_BYTES_COUNT` says. Headers are never +counted. What the visitor sent over a WebSocket counts as request bytes, and +what the app sent as response bytes. The bytes counted for a request and its +response are added to the client's total once the response has ended (see step +18). So the request that takes a client past a limit is passed on whole, and the +ban refuses the client's requests from then on. By default there is no minute +limit, so a client can download in a burst at full speed. The hour limit +defaults to 50 GiB and the day limit to 100 GiB. They allow several downloads of +a few GiB each. ### Lookup of AS number and country @@ -478,13 +484,13 @@ lookup works with no setup. A local database file is the alternative for a service that keeps its visitors' addresses on its own host. - Only one source is used. The start stops, naming both settings, when `file` is - set without `SWW_LOOKUP_DB_PATH`, or `SWW_LOOKUP_DB_PATH` with any other - source. The start also stops when the source is `off` and a setting that needs - a lookup is set (`docs/settings.md` lists them). + set without `SWW_WAF_LOOKUP_DB_PATH`, or `SWW_WAF_LOOKUP_DB_PATH` with any + other source. The start also stops when the source is `off` and a setting that + needs a lookup is set (`docs/settings.md` lists them). - GeoJS is told the address of every new visitor, whether or not a setting uses the answer, since the request log, the metrics and the ban notes carry it. These addresses are never sent: - - one in `SWW_ALLOW_NETS` or `SWW_DENY_NETS`; + - one in `SWW_WAF_ALLOW_NETS` or `SWW_WAF_DENY_NETS`; - one under a ban; - a private, loopback or link-local address, which no source can place. - For an IPv6 visitor, GeoJS is asked about the first address of its group. @@ -497,11 +503,11 @@ service that keeps its visitors' addresses on its own host. are kept apart from the table of clients, so that an answer outlasts a client dropped from that table. The answer reaches the client's history and ban notes when it comes. -- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's - first answer, when a setting needs it (see step 6). It also bounds each - request to GeoJS. A client whose answer has not come in time counts as unknown - until it comes, and its later requests do not wait. Its address is asked about - again in the background. +- `SWW_WAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its + client's first answer, when a setting needs it (see step 6). It also bounds + each request to GeoJS. A client whose answer has not come in time counts as + unknown until it comes, and its later requests do not wait. Its address is + asked about again in the background. - A client the lookup cannot place has an unknown country and AS number, which the country list and the biased thresholds treat as described there. Nothing else treats it differently. @@ -525,23 +531,23 @@ service that keeps its visitors' addresses on its own host. ### Country list -`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a -client by what it does, not by where it comes from. A `country=` tag in -`SWW_TAG_DENY` refuses one country (see "Request tags"). +`SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge +a client by what it does, not by where it comes from. A `country=` tag in +`SWW_WAF_TAG_DENY` refuses one country (see "Request tags"). -- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a +- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a listed country. Every request from any other country is refused. So is every request from a client the lookup cannot place, such as internal monitoring or a visitor on a private address; those that should reach the app go in - `SWW_ALLOW_NETS`. A list that let unplaced clients through would let every new - client in whenever GeoJS stops answering. The cost: while GeoJS fails, this - list refuses every new client. No other failure of GeoJS, a reputation source - or an alert destination comes this close to taking the service down. -- A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start - with a message naming it. -- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's - address has been looked up. It is logged with the action `country_denied`, and - counted in the metrics. + `SWW_WAF_ALLOW_NETS`. A list that let unplaced clients through would let every + new client in whenever GeoJS stops answering. The cost: while GeoJS fails, + this list refuses every new client. No other failure of GeoJS, a reputation + source or an alert destination comes this close to taking the service down. +- A code in this list whose `country=` tag is in `SWW_WAF_TAG_DENY` stops the + start with a message naming it. +- A refused request is answered with `SWW_WAF_BAN_RESPONSE`, as soon as the + client's address has been looked up. It is logged with the action + `country_denied`, and counted in the metrics. ### Request tags @@ -556,17 +562,17 @@ Each tag and where it comes from: from the lookup. A client the lookup cannot place has neither. A request refused by `deny_nets` or `active_ban` has neither, because those checks come before the lookup. -- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the - tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are - fetched and kept like a blocklist. `discriminator` ships none of its own. +- One tag per list of `SWW_WAF_TAG_LISTS` that holds the client (`tag_lists`), + the tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists + are fetched and kept like a blocklist. `discriminator` ships none of its own. - `crowdsec`, `dnsbl=` and `abuseipdb`, when that reputation source lists the client, whatever the source's action. A zone with a key is named with the key masked, as the `reputation` field gives it. - `exploitsignature`, when a rule file or the Core Rule Set matched the request. For the Core Rule Set a match is a score at or over - `SWW_WAF_ANOMALY_THRESHOLD`. + `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`. - `asn=` and `prefix=` from a routewatch instance - (`routewatch_tags`), while `SWW_ROUTEWATCH_URL` is set: + (`routewatch_tags`), while `SWW_WAF_ROUTEWATCH_URL` is set: - The tag `prefix=` holds the netblock the client's address is announced in, such as `prefix=192.0.2.0/24`. - An `asn=` tag has the same spelling as the lookup's. When routewatch and @@ -581,26 +587,26 @@ Each tag and where it comes from: `routewatch_tags_missing` (see [`docs/request-log.md`](docs/request-log.md)). -A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in -lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and -`SWW_TAG_LIMIT_PERCENT` accept tags in either case. +A client in `SWW_WAF_ALLOW_NETS` skips the checks and gets no tag. Every tag is +in lower case, except a country code, which is in capitals. `SWW_WAF_TAG_DENY` +and `SWW_WAF_TAG_LIMIT_PERCENT` accept tags in either case. Two settings act on tags: -- `SWW_TAG_DENY` lists tags whose requests are refused with the action +- `SWW_WAF_TAG_DENY` lists tags whose requests are refused with the action `client_denied` (`tag_deny`). The check runs twice: just before the rate limits, and again after the Core Rule Set, so that `exploitsignature` can refuse too. -- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their +- `SWW_WAF_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their clients get that percentage of every request and byte limit. The lowest percentage among a client's tags applies. `exploitsignature` cannot be listed, since a request gets it only after the rate limits have counted it. The tag settings are empty by default, for the same reason as the country list. The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A -setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. The -exception is an `asn=` tag while `SWW_ROUTEWATCH_URL` is set, since routewatch -gives that tag. +setting that names one stops the start while `SWW_WAF_LOOKUP_SOURCE` is `off`. +The exception is an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is set, since +routewatch gives that tag. ### Biased thresholds @@ -609,22 +615,23 @@ The lists are empty by default, for the same reason as the country list. The request log and the metrics show which AS numbers and countries a service's abuse comes from. -- `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see - "Request tags"). A lowered limit is rounded down. -- `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first +- `SWW_WAF_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` + (see "Request tags"). A lowered limit is rounded down. +- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first request breaks a limit and bans it. -- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits' - percentage alone, when it should differ from the request limits'. For an AS - number they list, they replace its `asn=` tag's percentage and its entry in - `SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage. -- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one - abuse-source list can be shared by every `discriminator` in the fleet. It is - fetched and kept like a blocklist (see "Reputation"). Each entry counts like - an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the - lower applies. +- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` set the byte + limits' percentage alone, when it should differ from the request limits'. For + an AS number they list, they replace its `asn=` tag's percentage and its entry + in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's + percentage. +- `SWW_WAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so + one abuse-source list can be shared by every `discriminator` in the fleet. It + is fetched and kept like a blocklist (see "Reputation"). Each entry counts + like an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT`. Where both give a + percentage, the lower applies. - A client's limit percentage is the lowest of those that apply to it: its AS number's, its country's, its tags', or a reputation hit's `limit:`. A - client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a + client without a country gets `SWW_WAF_UNKNOWN_LIMIT_PERCENT` in place of a country's. The percentage is 100 if none applies. Clients given a lower percentage are banned after fewer requests, first for a while and then, for repeated abuse, permanently. The log line of each counted request, and the @@ -632,7 +639,7 @@ abuse comes from. - No budget is shared by a whole country or AS number. One abuser could use it up and lock out everyone else there: a denial of service nobody chose, and the one this tool exists to prevent. Refusing a whole country is left to the - operator's choice, through `SWW_TAG_DENY` or the country list. + operator's choice, through `SWW_WAF_TAG_DENY` or the country list. ### Attack detection @@ -645,19 +652,20 @@ in the web editor. The Core Rule Set takes the shell commands and code in them for attacks. The default gives up refusing an attack carried in a body. The rule files, the limits and the bans still apply to the client that sends it. -- The Core Rule Set skips a path under `SWW_WAF_EXEMPT_PATHS`. -- `SWW_WAF_PARANOIA_LEVEL` and `SWW_WAF_ANOMALY_THRESHOLD` are the Core Rule - Set's own two tuning values, at its own defaults. Each level up runs more - rules, which find more attacks and take more ordinary requests for attacks. - Each rule that matches adds its score to the request's, and a rule rated - critical adds 5. A request whose score reaches the threshold is a match, so by - default one critical rule is enough. With the threshold `off`, nothing is a - match, and scores are still logged. A request with more than 1000 query - parameters or body fields adds 5 to its score, since Coraza reads only the - first 1000. For a match, in `block` and `detect` mode alike, the log line - gives the rule ids and the score, and a `core_rule_set_match` alert is sent. -- `SWW_WAF_DISABLED_RULES` switches off rule ids when an app trips a false - positive. The default switches off six rules: +- The Core Rule Set skips a path under `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS`. +- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` and + `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` are the Core Rule Set's own two + tuning values, at its own defaults. Each level up runs more rules, which find + more attacks and take more ordinary requests for attacks. Each rule that + matches adds its score to the request's, and a rule rated critical adds 5. A + request whose score reaches the threshold is a match, so by default one + critical rule is enough. With the threshold `off`, nothing is a match, and + scores are still logged. A request with more than 1000 query parameters or + body fields adds 5 to its score, since Coraza reads only the first 1000. For a + match, in `block` and `detect` mode alike, the log line gives the rule ids and + the score, and a `core_rule_set_match` alert is sent. +- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off rule ids when an app trips + a false positive. The default switches off six rules: - 920340, 920420 and 920640 refuse a request for the type of its body, when the type is missing or not on the Core Rule Set's short list; - 920440 refuses a request for its file extension, such as `.sh` or `.sql`; @@ -671,20 +679,20 @@ files, the limits and the bans still apply to the client that sends it. probes these rules caught (see "Rule files"). A list you give replaces the default, so name these six ids in it to keep them off. -- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as +- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as Coraza directives. An exclusion switches one rule off for some fields or paths - only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file - holds an app's own exclusions; the eighth change below holds gitea's. + only, where `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches it off everywhere. + The file holds an app's own exclusions; the eighth change below holds gitea's. - `discriminator` loads the file after `smallwebwaf`'s own setup rules and before the Core Rule Set's rules. The Core Rule Set loads its own `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point. - So the file holds only `SecAction` and `SecRule` lines that exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. These run with each request, before the rules they exclude, those of phase 1 included. - - A rule that `SWW_WAF_DISABLED_RULES` switches off and the file names is - simply off. + - A rule that `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off and the + file names is simply off. - The start stops on a file that cannot be read or does not parse, whatever - `SWW_WAF_MODE` is. + `SWW_WAF_CORE_RULE_SET_MODE` is. - It stops too on a file that names a number from 900000 to 900999, the ids of `smallwebwaf`'s own rules, alone or in a range. Comment lines are not checked. @@ -692,23 +700,23 @@ files, the limits and the bans still apply to the client that sends it. loads, such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded yet. -- `SWW_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes at - most `64K`, since `discriminator` gets no more of a body than that (see "The - answer"). Form data and multipart bodies are read up to the size. JSON and XML - bodies are read only when no larger than the size, since those cannot be read - in part. Any other body is not read: the Core Rule Set would read it as form - data, where binary content such as a git push trips rules written for text. A - body the Core Rule Set cannot parse adds 5 to the score, and so does a - multipart body cut by the limit in the middle of a part's header. Body - inspection suits apps whose forms carry no code. -- `SWW_TRAP_PATHS` names paths the app never serves and only scanners ask for, - such as `/wp-login.php` in front of an app that is not WordPress. This is the - short form of a `path` rule with the `ban` action, for deployments that mount - no rule files. A trap path is matched whole, without the query, against the - path as sent and once percent-decoded: `/wp-login.php` matches +- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` switches body inspection on, up to that + size. It takes at most `64K`, since `discriminator` gets no more of a body + than that (see "The answer"). Form data and multipart bodies are read up to + the size. JSON and XML bodies are read only when no larger than the size, + since those cannot be read in part. Any other body is not read: the Core Rule + Set would read it as form data, where binary content such as a git push trips + rules written for text. A body the Core Rule Set cannot parse adds 5 to the + score, and so does a multipart body cut by the limit in the middle of a part's + header. Body inspection suits apps whose forms carry no code. +- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask + for, such as `/wp-login.php` in front of an app that is not WordPress. This is + the short form of a `path` rule with the `ban` action, for deployments that + mount no rule files. A trap path is matched whole, without the query, against + the path as sent and once percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/` or `/blog/wp-login.php`. -- `SWW_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these +- `SWW_WAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these kinds, and of no other: - a rule file match; - a trap path; @@ -852,11 +860,11 @@ case, as Coraza matches them. ### Ban lengths, response and scope -`SWW_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise when -debugging; `429` and `close` are the alternatives. Behind traefik, `close` does -not leave the client unanswered: traefik answers `502`, as it does whenever its -backend drops a connection. `SWW_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban to the -surrounding netblock. The four ban lengths are under "Bans". +`SWW_WAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise +when debugging; `429` and `close` are the alternatives. Behind traefik, `close` +does not leave the client unanswered: traefik answers `502`, as it does whenever +its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban +to the surrounding netblock. The four ban lengths are under "Bans". ### Reputation @@ -868,9 +876,9 @@ Spamhaus may block an address that breaks them. Each `discriminator` fetches its own copy, so several of them on one host, sharing one address, would often fetch too close together. -- Blocklists (`SWW_BLOCKLIST_URLS`) are text files of addresses and netblocks, - one to a line, as the Spamhaus DROP list is written. Anything after a `;` or - `#` on a line is ignored. An IPv4-mapped address such as +- Blocklists (`SWW_WAF_BLOCKLIST_URLS`) are text files of addresses and + netblocks, one to a line, as the Spamhaus DROP list is written. Anything after + a `;` or `#` on a line is ignored. An IPv4-mapped address such as `::ffff:192.0.2.0/120` is read as the IPv4 netblock it stands for. Spamhaus's terms for DROP, on its DROP page: - automated downloads must be at least one hour apart, and once a day is @@ -878,10 +886,10 @@ too close together. - a product that uses it must credit The Spamhaus Project and keep the list's date and copyright lines with the data. - So `SWW_BLOCKLIST_REFRESH` is never less than `1h`, and the last good copy - is kept whole, comment lines included, on failure and across restarts. + So `SWW_WAF_BLOCKLIST_REFRESH` is never less than `1h`, and the last good + copy is kept whole, comment lines included, on failure and across restarts. -- Each list is fetched again `SWW_BLOCKLIST_REFRESH` after its last try, one +- Each list is fetched again `SWW_WAF_BLOCKLIST_REFRESH` after its last try, one list at a time. `reputation.json` keeps when each was last tried, so a restart does not fetch a list again before its time. A fetch fails when: - the server answers other than `200`; @@ -893,28 +901,28 @@ too close together. The log, the alerts, the ban notes and the metrics name each list by its URL, so a list's URL must not hold a secret. -- `SWW_BLOCKLIST_ACTION` is `deny` by default, which refuses every request from - a listed address and suits lists of networks that send nothing legitimate, - such as DROP. `limit:` gives listed clients that percentage of every - request and byte limit, and suits a list of addresses shared with ordinary - visitors, such as Tor exits. `log` only notes the hit in the log line and - raises an alert. -- A CrowdSec engine on the host (`SWW_CROWDSEC_LAPI_URL`) has its decision list - fetched every minute, with `SWW_CROWDSEC_LAPI_KEY` in the `X-Api-Key` header. - The list is kept like a fetched blocklist, and a client on it is banned (see - "Bans"). Only decisions of the type `ban` on an address or a netblock are - used; any other, such as a captcha or a country, is left out. A decision lasts - for the `duration` it had left when the engine answered. This is how a - fleet-wide blocklist can arrive without `discriminator` depending on CrowdSec. - A fetch fails when: +- `SWW_WAF_BLOCKLIST_ACTION` is `deny` by default, which refuses every request + from a listed address and suits lists of networks that send nothing + legitimate, such as DROP. `limit:` gives listed clients that + percentage of every request and byte limit, and suits a list of addresses + shared with ordinary visitors, such as Tor exits. `log` only notes the hit in + the log line and raises an alert. +- A CrowdSec engine on the host (`SWW_WAF_CROWDSEC_LAPI_URL`) has its decision + list fetched every minute, with `SWW_WAF_CROWDSEC_LAPI_KEY` in the `X-Api-Key` + header. The list is kept like a fetched blocklist, and a client on it is + banned (see "Bans"). Only decisions of the type `ban` on an address or a + netblock are used; any other, such as a captcha or a country, is left out. A + decision lasts for the `duration` it had left when the engine answered. This + is how a fleet-wide blocklist can arrive without `discriminator` depending on + CrowdSec. A fetch fails when: - the engine answers other than `200`, a redirect included; - the fetch does not finish within a minute; - the answer is longer than 16 MiB; - the answer is not a list of decisions. -- Each DNS blocklist (`SWW_DNSBL_ZONES`) is asked about a client's own address - in the background, the first time the client is seen. No request waits for the - answer. The name asked about is the one RFC 5782 gives: the numbers of an IPv4 - address reversed, or the 32 hex digits of an IPv6 address reversed. So +- Each DNS blocklist (`SWW_WAF_DNSBL_ZONES`) is asked about a client's own + address in the background, the first time the client is seen. No request waits + for the answer. The name asked about is the one RFC 5782 gives: the numbers of + an IPv4 address reversed, or the 32 hex digits of an IPv6 address reversed. So `192.0.2.99` is asked about as `99.2.0.192.dnsbl.dronebl.org`. An answer in `127.0.0.0/8` lists the client, but one in `127.255.255.0/24` is Spamhaus refusing the query. A name that does not exist does not list the client. These @@ -922,54 +930,55 @@ too close together. - an answer in `127.255.255.0/24`; - an answer outside `127.0.0.0/8`; - a resolver error; - - no answer within `SWW_REPUTATION_TIMEOUT`. + - no answer within `SWW_WAF_REPUTATION_TIMEOUT`. DNS blocklists made for mail servers list residential ranges, and would block ordinary visitors, so they should not be used. Spamhaus zones need their keyed query service, given as a zone name that contains the key. `discriminator` shows the key as `********` wherever it names the zone. Only - `reputation.json` keeps the key. `SWW_DNSBL_RESOLVER` names a resolver, + `reputation.json` keeps the key. `SWW_WAF_DNSBL_RESOLVER` names a resolver, since public resolvers are refused by several list operators. -- AbuseIPDB (`SWW_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the abuse - confidence score of a client, from 0 to 100. Only a client that has already - committed one offence is checked, so the daily budget - (`SWW_ABUSEIPDB_DAILY_BUDGET`, under the free tier's 1000 checks a day) is +- AbuseIPDB (`SWW_WAF_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the + abuse confidence score of a client, from 0 to 100. Only a client that has + already committed one offence is checked, so the daily budget + (`SWW_WAF_ABUSEIPDB_DAILY_BUDGET`, under the free tier's 1000 checks a day) is spent on suspects. The check runs in the background at the client's next request that no ban refuses, so no request waits; the score applies to the client's later requests. For an IPv6 client, AbuseIPDB is asked about the address of the request that set off the check, and the score is used for the - whole group. A score at or over `SWW_ABUSEIPDB_MIN_SCORE` is a hit. The budget - is counted in UTC days and kept in `reputation.json`, so a restart does not - make it whole again. The check that spends the last of it is logged and + whole group. A score at or over `SWW_WAF_ABUSEIPDB_MIN_SCORE` is a hit. The + budget is counted in UTC days and kept in `reputation.json`, so a restart does + not make it whole again. The check that spends the last of it is logged and alerted, and no client is checked until the day ends. The key is sent in the `Key` header and nowhere else, and is never logged. A check fails, after which no client is checked for a minute, when: - AbuseIPDB answers other than `200`; - the answer gives no score; - - no answer comes within `SWW_REPUTATION_TIMEOUT`. -- `SWW_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is `limit:25` - by default. Such verdicts are less certain than a blocklist, so a listed - client gets a quarter of every request and byte limit rather than a refusal. -- Verdicts and scores are kept for `SWW_REPUTATION_CACHE_TTL`, in memory and in - `reputation.json`. A source that is down or over quota gives no verdict. The - service continues. Each failure raises a `source_failure` alert (see + - no answer comes within `SWW_WAF_REPUTATION_TIMEOUT`. +- `SWW_WAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is + `limit:25` by default. Such verdicts are less certain than a blocklist, so a + listed client gets a quarter of every request and byte limit rather than a + refusal. +- Verdicts and scores are kept for `SWW_WAF_REPUTATION_CACHE_TTL`, in memory and + in `reputation.json`. A source that is down or over quota gives no verdict. + The service continues. Each failure raises a `source_failure` alert (see "Alerting"). ### Alerting - Alerts go to a webhook as JSON, and to Slack and ntfy as formatted messages. `docs/alerts.md` lists the events, the JSON object and the messages. - `SWW_ALERT_EVENTS` chooses which of the seven event types are sent; the hourly - `summary` is sent whatever it says. The bans an admin makes raise no alert. In - `observe` mode, a request that would have made a ban still raises the ban's - alert, with `mode` set to `observe` in its `detail`. + `SWW_WAF_ALERT_EVENTS` chooses which of the seven event types are sent; the + hourly `summary` is sent whatever it says. The bans an admin makes raise no + alert. In `observe` mode, a request that would have made a ban still raises + the ban's alert, with `mode` set to `observe` in its `detail`. - A repeat is an alert with the same event, and the same netblock, source, file - or anomaly scope, as one sent less than `SWW_ALERT_COOLDOWN` before. An + or anomaly scope, as one sent less than `SWW_WAF_ALERT_COOLDOWN` before. An anomaly scope is one of the things counted under "Anomaly thresholds". A repeat is held back and counted, and the next such alert sent gives the count. -- Past `SWW_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and - counted by event, so a wide attack cannot flood the channel. Once the hour +- Past `SWW_WAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back + and counted by event, so a wide attack cannot flood the channel. Once the hour ends, one alert with the event `summary` gives those counts. It also counts the repeats that a cooldown held back and that no later alert reported. - Each destination has a bounded queue (see "Fixed limits" in @@ -987,26 +996,26 @@ left unset sends no alert. An alert needs a destination only the operator can supply, and what counts as unusual depends on each service's normal traffic, which the metrics show. They reveal a widely distributed scraper, thousands of addresses at low rates each, which the per-client limits do not see. A low -percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response. +percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response. -- Requests and body bytes, as `SWW_BYTES_COUNT` counts them, are counted per +- Requests and body bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted per minute and per hour, for each of: - a client; - the surrounding netblock; - an AS number; - the whole service; - - a named netblock in `SWW_WATCH_NETS`. + - a named netblock in `SWW_WAF_WATCH_NETS`. - Every request `discriminator` answers with `allow`, `deny` or `close` is counted, an allowed one when its report comes. That holds whatever was done - with it, and for clients in `SWW_ALLOW_NETS` and `SWW_RATE_LIMIT_EXEMPT_NETS`, - since an exempt client misbehaving is worth knowing about. The counters are - kept in memory and in `alerts.json`. + with it, and for clients in `SWW_WAF_ALLOW_NETS` and + `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`, since an exempt client misbehaving is worth + knowing about. The counters are kept in memory and in `alerts.json`. ## Rule files -`discriminator` reads every `*.rules` file in `SWW_RULES_DIR` and checks each -request against them. `docs/rules.md` gives the format, the targets and actions, -and the default file. +`discriminator` reads every `*.rules` file in `SWW_WAF_RULES_DIR` and checks +each request against them. `docs/rules.md` gives the format, the targets and +actions, and the default file. - The files are plain text meant to be edited by hand, so that a new scanning pattern seen in the request log becomes a rule in one line. `discriminator` @@ -1035,7 +1044,7 @@ and the default file. - a regex that does not compile; - a duplicate id. - So does a `SWW_RULES_DIR` that does not exist or cannot be read. While + So does a `SWW_WAF_RULES_DIR` that does not exist or cannot be read. While running, the same faults leave the rules as they were, and the log and one `file_error` alert name the file and line. Once the file is fixed, it is read again. An empty directory is not an error: the log says that no rules @@ -1047,18 +1056,18 @@ and the default file. `///src/branch/main/.env.example` is a file in a repository that any visitor may open. Anchor the paths of your own `ban` rules at the site root too. A rule has one target, so a `path` rule applies on every site behind - that `discriminator`. A directory mounted over `SWW_RULES_DIR` replaces the - default file; single files mounted into it add to it. + that `discriminator`. A directory mounted over `SWW_WAF_RULES_DIR` replaces + the default file; single files mounted into it add to it. - Rules meant to be edited while `discriminator` runs belong in a mounted directory, not a mounted file. Many editors save a file by replacing it, and a container keeps seeing the old one when the file alone is mounted. The lookup database is mounted the same way, for the same reason. To run without rule - files, mount an empty directory or set `SWW_RULES_ENABLED=false`. + files, mount an empty directory or set `SWW_WAF_RULES_ENABLED=false`. ## Persistent state -All state lives in memory, and the files in `SWW_STATE_DIR` hold a copy of it, -so an orderly stop loses nothing. The files are meant for people as well: an +All state lives in memory, and the files in `SWW_WAF_STATE_DIR` hold a copy of +it, so an orderly stop loses nothing. The files are meant for people as well: an admin can read or edit them at any time, and the running `discriminator` takes the edit in. `docs/state-files.md` lists the files, what each holds, and their sizes. @@ -1113,8 +1122,8 @@ sizes. in the metrics and sent as one `file_error` alert per cooldown, and retried at the next write. - A hard kill loses the changes since the last write: ban changes from the last - `SWW_STATE_WRITE_DELAY`, everything else from the last - `SWW_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not + `SWW_WAF_STATE_WRITE_DELAY`, everything else from the last + `SWW_WAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not service. ## Request log @@ -1127,31 +1136,31 @@ carries secrets in query strings needs that fixed in the app. Each process's own messages (start, the settings, stop, errors) share its stream as JSON lines with `"type":"process"`; request lines carry `"type":"request"`. -When `SWW_LOG_REMOTE_URL` is set, every line `discriminator` writes on stdout is -also sent there as an RFC 5424 syslog record. So a deployment can stop depending -on docker's log handling while `docker logs` keeps working. `rproxy` writes its -lines to stdout only. +When `SWW_WAF_LOG_REMOTE_URL` is set, every line `discriminator` writes on +stdout is also sent there as an RFC 5424 syslog record. So a deployment can stop +depending on docker's log handling while `docker logs` keeps working. `rproxy` +writes its lines to stdout only. - Over UDP each record is one datagram; a line too long for one datagram is dropped alone. Over TCP and TLS each record follows its length in bytes and a space. UDP gives no delivery signal at all and is offered only for compatibility. -- The record carries the facility from `SWW_LOG_REMOTE_FACILITY`, the severity - informational, the time, the host's name and the app name from - `SWW_LOG_REMOTE_APP_NAME`. -- Sending happens on its own goroutine from a buffer of `SWW_LOG_REMOTE_BUFFER` - lines, so a server that is slow or down never holds up a request and never - stops stdout. When the buffer is full, the oldest line is dropped and counted - in the metrics. A failed connection is tried again a second later, and twice - as long after each further failure, up to a minute. +- The record carries the facility from `SWW_WAF_LOG_REMOTE_FACILITY`, the + severity informational, the time, the host's name and the app name from + `SWW_WAF_LOG_REMOTE_APP_NAME`. +- Sending happens on its own goroutine from a buffer of + `SWW_WAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never + holds up a request and never stops stdout. When the buffer is full, the oldest + line is dropped and counted in the metrics. A failed connection is tried again + a second later, and twice as long after each further failure, up to a minute. - As `discriminator` stops, it spends at most two seconds sending the lines still waiting, and drops the rest; stdout has carried them. ## Metrics endpoint `GET /_sww/metrics`, at `discriminator`'s own address, answers in the Prometheus -text format, for a request carrying `SWW_METRICS_TOKEN`. `docs/metrics.md` lists -the metrics. No metric carries a client IP address as a label. Per-address +text format, for a request carrying `SWW_WAF_METRICS_TOKEN`. `docs/metrics.md` +lists the metrics. No metric carries a client IP address as a label. Per-address questions are answered by the request log and `GET /_sww/clients/`. ## Admin endpoints @@ -1188,9 +1197,9 @@ This section is the agreement between the two. The header names, the fields and the statuses below are exact. `discriminator` reads the settings, the rule files, the lookup database and the -state files, and listens at `SWW_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it stops -taking requests and gives those in progress five seconds to finish. It then -writes the state files. +state files, and listens at `SWW_WAF_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it +stops taking requests and gives those in progress five seconds to finish. It +then writes the state files. ### The question @@ -1270,19 +1279,19 @@ the metrics. order. Every answer but the last below carries `X-Sww-Disposition`. - `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request - to the app. With `SWW_ADD_LOOKUP_HEADERS` the answer also carries + to the app. With `SWW_WAF_ADD_LOOKUP_HEADERS` the answer also carries `X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it passes on. A header whose value is unknown is left out. - `X-Sww-Disposition: deny`, with the status, headers and body the visitor is to get. `rproxy` gives them to the visitor, less this header. The status is one of: - - `SWW_BAN_RESPONSE`, for a refusal; + - `SWW_WAF_BAN_RESPONSE`, for a refusal; - `403`, for a `block` rule or the Core Rule Set; - `431`, for a request over the header limits; - `404`, for a path under `/_sww/` that passes the checks, so that the app never sees that prefix. - `X-Sww-Disposition: close`, status `200`, no body. This takes the place of a - refusal while `SWW_BAN_RESPONSE` is `close`. `rproxy` closes the visitor's + refusal while `SWW_WAF_BAN_RESPONSE` is `close`. `rproxy` closes the visitor's connection without an answer. - No such header, status `400`, for a question `discriminator` refuses. `rproxy` gives the visitor its status, headers and body, as it does for any answer @@ -1293,38 +1302,39 @@ In `observe` mode a request that `enforce` mode would refuse is answered answered `404`. The Core Rule Set inspects the body a question carries only while -`SWW_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it -does not inspect a JSON or XML body of 64 KiB or more, which may have been cut -short. +`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. It inspects at most 64 KiB less one +byte. So it does not inspect a JSON or XML body of 64 KiB or more, which may +have been cut short. ### Answering in time -`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2 -seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. The longest a question -waits on an outside source is `SWW_LOOKUP_TIMEOUT`, by default 800 milliseconds, -well within that time. +`rproxy` stops waiting for `discriminator` after `SWW_RPROXY_TIMEOUT_MS`, by +default 2 seconds, and `SWW_RPROXY_DEFAULT_DISPOSITION` then decides. The +longest a question waits on an outside source is `SWW_WAF_LOOKUP_TIMEOUT`, by +default 800 milliseconds, well within that time. GeoJS is the only outside source a question can wait on. A question waits only -when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800 +when step 6 says so, and for at most `SWW_WAF_LOOKUP_TIMEOUT`, by default 800 milliseconds. A client without an answer by then counts as unknown. No question waits on these, which run in the background: - the DNS blocklists and AbuseIPDB; - fetching the blocklists, the tag lists, the CrowdSec decision list and the - file `SWW_ASN_LIMIT_PERCENT_URL` names. + file `SWW_WAF_ASN_LIMIT_PERCENT_URL` names. In `enforce` mode, a question does not wait for GeoJS when it is a request: - over the header limits; -- from a client on a fetched blocklist, while `SWW_BLOCKLIST_ACTION` is `deny`; +- from a client on a fetched blocklist, while `SWW_WAF_BLOCKLIST_ACTION` is + `deny`; - from a client on the CrowdSec decision list; - that would take its client over a rate limit at 100% of it, since a percentage can only lower a limit. Such a request still goes through the steps in their order. Its client's AS number and country stay unknown until GeoJS has answered. So while -`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first. -`SWW_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting +`SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first. +`SWW_WAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting never lowers its limit. The static lists and bans come before the lookup, so they never wait. These can @@ -1332,7 +1342,7 @@ still wait for GeoJS: - a country list, which needs the answer; - a rate limit a percentage has lowered, which needs the answer; -- a DNS blocklist or AbuseIPDB, while `SWW_REPUTATION_ACTION` is `deny`; +- a DNS blocklist or AbuseIPDB, while `SWW_WAF_REPUTATION_ACTION` is `deny`; - a trap path; - a rule file; - the Core Rule Set. @@ -1350,15 +1360,15 @@ log line's action is `request_header_too_large`. The refusal makes no ban and is no offence. The rate limits count the request as usual, so a client that keeps sending such requests breaks a limit. -The limits apply on every path and in every `SWW_WAF_MODE`. A client in -`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode -such a request is answered `allow` with `would_action`, and the Core Rule Set -does not read it. +The limits apply on every path and in every `SWW_WAF_CORE_RULE_SET_MODE`. A +client in `SWW_WAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In +`observe` mode such a request is answered `allow` with `would_action`, and the +Core Rule Set does not read it. The Core Rule Set runs each of its rules on each cookie, so thousands of small cookies would keep it busy for over a second. Within the limits, one long cookie or query string can still keep it busy for a fraction of a second. So enough -such requests at once can overload `discriminator` past `SWRP_TIMEOUT_MS`. +such requests at once can overload `discriminator` past `SWW_RPROXY_TIMEOUT_MS`. ### The report @@ -1398,8 +1408,8 @@ answer. For a request it holds, `discriminator` writes the request's last log line (see "The log"). For a request it allowed, it first: -- counts the body bytes `SWW_BYTES_COUNT` names, toward the client's byte limits - at the percentages it gave that request; +- counts the body bytes `SWW_WAF_BYTES_COUNT` names, toward the client's byte + limits at the percentages it gave that request; - bans a client whose total passes a byte limit; - adds the request with its status to the client's history, the anomaly counters and the metrics. @@ -1464,22 +1474,22 @@ raised by a request and ban notes name the site by its host. `sww rproxy` is the reverse proxy in front of one app. It asks `discriminator` about each request, and passes on what `discriminator` allows. It keeps nothing -between requests and never touches disk. When `discriminator` cannot be +between requests and writes nothing to disk. When `discriminator` cannot be connected to, does not answer at all, or does not answer within -`SWRP_TIMEOUT_MS`, `SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`, -keeps the site answering. +`SWW_RPROXY_TIMEOUT_MS`, `SWW_RPROXY_DEFAULT_DISPOSITION` decides. Its default, +`allow`, keeps the site answering. -`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of -them is required: `SWRP_WAF_BACKEND`, `discriminator`'s URL. It has no metrics, -no rule files and no state files, and it writes its lines to stdout only. The -image runs it by default (see "Deployment"). +`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists. +One of them is required: `SWW_RPROXY_WAF_BACKEND`, `discriminator`'s URL. It has +no metrics, no rule files and no state files, and it writes its lines to stdout +only. The image runs it by default (see "Deployment"). ### What `rproxy` does with a request 1. `GET /_sww/healthz` is answered `ok` at once, without asking `discriminator`. 2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only - from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a - request" says. It removes any IPv6 zone. + from `SWW_RPROXY_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to + a request" says. It removes any IPv6 zone. 3. It makes a fresh request id, whatever id the request came with. `discriminator` and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it. @@ -1545,13 +1555,13 @@ log line has status `0`. ### Without an answer from `discriminator` -`SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when +`SWW_RPROXY_DEFAULT_DISPOSITION` decides what is done with the request only when `discriminator`: - cannot be connected to; - does not answer at all; -- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the - question and reading the whole answer. +- does not answer within `SWW_RPROXY_TIMEOUT_MS`, which covers connecting, + sending the question and reading the whole answer. An answer whose body breaks off counts as no answer at all. @@ -1560,9 +1570,9 @@ Either way `rproxy` writes a process warning, its log line has `default_disposition`, and it sends no report. A visitor can make `discriminator` slow. A request that keeps `discriminator` -busy past `SWRP_TIMEOUT_MS`, like any request while `discriminator` is -overloaded, gets `SWRP_DEFAULT_DISPOSITION`. With `deny`, such a request gets -`503` instead of reaching the app. +busy past `SWW_RPROXY_TIMEOUT_MS`, like any request while `discriminator` is +overloaded, gets `SWW_RPROXY_DEFAULT_DISPOSITION`. With `deny`, such a request +gets `503` instead of reaching the app. A path under `/_sww/` that `allow` would pass on is answered `404` instead, so that the app never sees that prefix. @@ -1578,18 +1588,19 @@ no report. Its log line has status `0` and no `disposition`. ### Passing a request to the app -`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of -the body it holds, then the rest, streaming. The app's answer comes back the -same way. Apart from what Go's HTTP code changes, the request reaches the app -unchanged but for these headers: +`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the +part of the body it holds, then the rest, streaming. The app's answer comes back +the same way. Apart from what Go's HTTP code changes, the request reaches the +app unchanged but for these headers: - `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that - peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass - on. -- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own - claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host` - the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers - in which traefik tells an app about a visitor are removed. + peer is inside `SWW_RPROXY_TRUSTED_PROXIES`. The peer's other forwarded + headers pass on. +- For a peer outside `SWW_RPROXY_TRUSTED_PROXIES`, its forwarded headers are its + own claims and are replaced. `X-Forwarded-For` names the peer, + `X-Forwarded-Host` the host it asked for, and `X-Forwarded-Proto` plain + `http`. The other headers in which traefik tells an app about a visitor are + removed. - `X-Request-ID`, which carries the fresh id. - `X-Client-ASN` and `X-Client-Country`, which carry what `discriminator` answered. A header it did not give is left out. Any of the two the visitor @@ -1613,15 +1624,15 @@ each way. These limits apply per request, in both directions: -- `SWRP_MAX_REQUEST_SIZE` is the largest request body. -- `SWRP_MAX_RESPONSE_SIZE` is the largest response body. -- `SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their - end, it then bounds the time until the app has been sent the whole request. - The wait for `discriminator` counts toward it. -- `SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte - of the answer reaching the visitor. The request ends when the app has been - sent all of it, or when the app's answer arrives if that is sooner. An answer - `rproxy` gives itself has the whole time from when it starts. +- `SWW_RPROXY_MAX_REQUEST_SIZE` is the largest request body. +- `SWW_RPROXY_MAX_RESPONSE_SIZE` is the largest response body. +- `SWW_RPROXY_MAX_REQUEST_TIME_MS` bounds the request line and headers. From + their end, it then bounds the time until the app has been sent the whole + request. The wait for `discriminator` counts toward it. +- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last + byte of the answer reaching the visitor. The request ends when the app has + been sent all of it, or when the app's answer arrives if that is sooner. An + answer `rproxy` gives itself has the whole time from when it starts. The largest request line and headers is 32 KiB. A kept-open connection is closed after 120 seconds idle. Neither has a setting. traefik, by default, closes an @@ -1648,7 +1659,7 @@ What the visitor gets when a limit is passed: - `502` for a response that is too large, or an app that cannot be reached; - `504` for an app that is too slow. - A request body streams through, so each side can hold up the other. - `SWRP_MAX_REQUEST_TIME_MS` can run out while the body is on its way. The + `SWW_RPROXY_MAX_REQUEST_TIME_MS` can run out while the body is on its way. The answer is then `408` if `rproxy` was waiting for the visitor, and `504` if it was waiting for the app. - Once the response has started it can only be cut off, and the connection is @@ -1664,8 +1675,8 @@ those at `info` or more severe are written. On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in progress five seconds to finish. It then waits for the reports on their way, -each for up to `SWRP_TIMEOUT_MS`. A report that comes due after that is dropped, -such as that of a WebSocket that closes later. +each for up to `SWW_RPROXY_TIMEOUT_MS`. A report that comes due after that is +dropped, such as that of a WebSocket that closes later. ## Deployment @@ -1679,14 +1690,14 @@ pinned. The usual setup is three containers on one private network: - the app; -- `rproxy`, which traefik routes to, with `SWRP_UPSTREAM_URL` naming the app and - `SWRP_WAF_BACKEND` naming `discriminator`; +- `rproxy`, which traefik routes to, with `SWW_RPROXY_UPSTREAM_URL` naming the + app and `SWW_RPROXY_WAF_BACKEND` naming `discriminator`; - `discriminator`, with `SWW_COMMAND=discriminator` and a volume for its state files. Each further app adds its own container and its own `rproxy`, whose -`SWRP_WAF_BACKEND` names the same `discriminator` (see "Sites"). Anything on -that network can reach an app around `rproxy`, and can ask `discriminator` +`SWW_RPROXY_WAF_BACKEND` names the same `discriminator` (see "Sites"). Anything +on that network can reach an app around `rproxy`, and can ask `discriminator` questions or send it reports. So the network holds only what you trust. ### `rproxy` in the app's container @@ -1695,7 +1706,7 @@ An app's Dockerfile can build `FROM` the `smallwebwaf` image instead, so that `rproxy` runs beside the app in one container. The app is then hardened by changing its base image, and can add packages from nixpkgs. `discriminator` still runs in a container of its own, and the app's container sets -`SWRP_WAF_BACKEND`. +`SWW_RPROXY_WAF_BACKEND`. Beyond its `FROM` line, the app's Dockerfile adds: @@ -1703,9 +1714,9 @@ Beyond its `FROM` line, the app's Dockerfile adds: - any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.`; - its runit service: a directory under `/etc/service` named after the app, never `sww`. Its `run` script starts the app with `chpst`, listening where - `SWRP_UPSTREAM_URL` points (see "Ports"). The app runs as a user of its own, - which the Dockerfile creates with `useradd`. That user is neither root nor - `sww`, so that the app cannot touch the process of `rproxy`. + `SWW_RPROXY_UPSTREAM_URL` points (see "Ports"). The app runs as a user of its + own, which the Dockerfile creates with `useradd`. That user is neither root + nor `sww`, so that the app cannot touch the process of `rproxy`. It sets no `ENTRYPOINT` or `USER` of its own. The container must start `runsvinit`, as root, so that it can start each service as its own user. @@ -1741,10 +1752,10 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start - The image's `run` script is `/etc/service/sww/run`. It starts `discriminator` when `SWW_COMMAND` is `discriminator`, and `rproxy` when it is `rproxy`, unset or empty. It runs either as the `sww` user (uid and gid 65532), with runit's - `chpst`. For `discriminator`, it first makes `SWW_STATE_DIR` and every file in - it belong to that user. For `rproxy` it changes no owner. With any other - value, it starts nothing, and stops with a message naming `SWW_COMMAND` and - its value. + `chpst`. For `discriminator`, it first makes `SWW_WAF_STATE_DIR` and every + file in it belong to that user. For `rproxy` it changes no owner. With any + other value, it starts nothing, and stops with a message naming `SWW_COMMAND` + and its value. Every `run` script, the app's included, is a bash script. It starts with `#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main` @@ -1762,17 +1773,17 @@ process that stops at start is started again every second, not in a tight loop. does not stop. Beside an app, the other process keeps running. - While `rproxy` is down, traefik answers `502`. While the app is down, `rproxy` answers `502`, which is not a client offence. While `discriminator` is down, - `SWRP_DEFAULT_DISPOSITION` decides (see "Without an answer from + `SWW_RPROXY_DEFAULT_DISPOSITION` decides (see "Without an answer from `discriminator`"). - Only faults found at start stop `discriminator`: - invalid configuration; - a lookup database that is missing or unreadable; - a rule file or state file that does not parse; - - an unwritable `SWW_STATE_DIR`; - - a `SWW_RULES_DIR` that does not exist or cannot be read; - - a `SWW_LISTEN_ADDR` it cannot listen on. -- Only an invalid setting, or a `SWRP_LISTEN_ADDR` it cannot listen on, stops - `rproxy`. + - an unwritable `SWW_WAF_STATE_DIR`; + - a `SWW_WAF_RULES_DIR` that does not exist or cannot be read; + - a `SWW_WAF_LISTEN_ADDR` it cannot listen on. +- Only an invalid setting, or a `SWW_RPROXY_LISTEN_ADDR` it cannot listen on, + stops `rproxy`. - A process that stops at start logs its message at each try until the cause is fixed; its container's health check fails meanwhile. Once running, a failing GeoJS, reputation source or alert destination, or a broken edit, never takes @@ -1793,11 +1804,12 @@ The image's `HEALTHCHECK` runs `sww healthcheck`. `SWW_COMMAND` says what it checks: - With `SWW_COMMAND=discriminator`, it passes while `discriminator` answers - `GET /_sww/healthz` on `127.0.0.1`, at the port in `SWW_LISTEN_ADDR`. + `GET /_sww/healthz` on `127.0.0.1`, at the port in `SWW_WAF_LISTEN_ADDR`. - With `SWW_COMMAND` unset, empty or `rproxy`, it passes while `rproxy` answers - that request on `127.0.0.1`, at the port in `SWRP_LISTEN_ADDR`, and the app - accepts connections at `SWRP_UPSTREAM_URL`. It does not ask `discriminator`, - so that the site stays in traefik's routes while `discriminator` is down. + that request on `127.0.0.1`, at the port in `SWW_RPROXY_LISTEN_ADDR`, and the + app accepts connections at `SWW_RPROXY_UPSTREAM_URL`. It does not ask + `discriminator`, so that the site stays in traefik's routes while + `discriminator` is down. - With any other value, it fails with a message naming `SWW_COMMAND` and its value. @@ -1808,14 +1820,14 @@ can replace the image's `HEALTHCHECK` with one that checks both. ### Ports -`rproxy` listens on no port but the one in `SWRP_LISTEN_ADDR`, 8080 by default, -which traefik routes to. It passes requests to the app at `SWRP_UPSTREAM_URL`, -`http://127.0.0.1:8081` by default. Another port may be set: the health check -follows the setting, and traefik's port label +`rproxy` listens on no port but the one in `SWW_RPROXY_LISTEN_ADDR`, 8080 by +default, which traefik routes to. It passes requests to the app at +`SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another port may +be set: the health check follows the setting, and traefik's port label (`traefik.http.services..loadbalancer.server.port`) must name it. An app beside `rproxy` must leave that port free. -`discriminator` listens on no port but the one in `SWW_LISTEN_ADDR`, 8080 by +`discriminator` listens on no port but the one in `SWW_WAF_LISTEN_ADDR`, 8080 by default. The questions, the reports and its own endpoints all come there. The address part of either setting stays empty (`:9000`, never @@ -1828,24 +1840,24 @@ the container. ### State -`discriminator` keeps its state files in `SWW_STATE_DIR`, belonging to the `sww` -user. Without a volume there, the files survive a restart of the container and -are lost when a deploy replaces it. A volume mounted at `/var/lib/sww`, named or -a host directory, keeps them across deploys. A host directory mounted there -needs no change of owner, and files an earlier owner left in it can be read and -replaced. The volume holds a few tens of MiB at most with the defaults, and -needs no backup beyond the host's own. The image declares no volume, since every -app image built on it would inherit it. `rproxy` keeps nothing, and needs no -volume. +`discriminator` keeps its state files in `SWW_WAF_STATE_DIR`, belonging to the +`sww` user. Without a volume there, the files survive a restart of the container +and are lost when a deploy replaces it. A volume mounted at `/var/lib/sww`, +named or a host directory, keeps them across deploys. A host directory mounted +there needs no change of owner, and files an earlier owner left in it can be +read and replaced. The volume holds a few tens of MiB at most with the defaults, +and needs no backup beyond the host's own. The image declares no volume, since +every app image built on it would inherit it. `rproxy` keeps nothing, and needs +no volume. ### Tokens -A token given as a file (`SWW_ADMIN_TOKEN_FILE`, `SWW_METRICS_TOKEN_FILE`) stays -out of the container's environment. The operator makes the file on the host, -owned by uid 65532, the `sww` user, with mode `0400`, and mounts the directory -that holds it into `discriminator`'s container read-only. The container sees the -same owner and mode. "Settings given as files" in `docs/settings.md` shows the -commands. +A token given as a file (`SWW_WAF_ADMIN_TOKEN_FILE`, +`SWW_WAF_METRICS_TOKEN_FILE`) stays out of the container's environment. The +operator makes the file on the host, owned by uid 65532, the `sww` user, with +mode `0400`, and mounts the directory that holds it into `discriminator`'s +container read-only. The container sees the same owner and mode. "Settings given +as files" in `docs/settings.md` shows the commands. ### Forwarded headers @@ -1868,9 +1880,9 @@ upaas rebuilds and restarts an app's Docker container when its repository changes. An app it deploys takes the shape of "`rproxy` in the app's container" with no change to upaas. upaas builds the app's Dockerfile and runs the one container as it runs any app. The traefik labels route to port 8080, and the -`SWRP_` settings go with the app's environment variables. `discriminator` runs -in a container of its own, on a network the app's container can reach, with its -state volume and the directory of any token file. +`SWW_RPROXY_` settings go with the app's environment variables. `discriminator` +runs in a container of its own, on a network the app's container can reach, with +its state volume and the directory of any token file. ## Planned, not built diff --git a/docs/admin-endpoints.md b/docs/admin-endpoints.md index c1f31da..27d6ee2 100644 --- a/docs/admin-endpoints.md +++ b/docs/admin-endpoints.md @@ -5,16 +5,16 @@ The endpoints under `/_sww/`, what each takes and what each answers. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. - `GET /_sww/healthz`: `200` and `ok`, to anyone. -- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see +- `GET /_sww/metrics`: the metrics, for `SWW_WAF_METRICS_TOKEN` (see [`metrics.md`](metrics.md)). - `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it. - Needs `SWW_ADMIN_TOKEN`, as do the three below. + Needs `SWW_WAF_ADMIN_TOKEN`, as do the three below. - `POST /_sww/bans`: bans a netblock. 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 its IPv4 netblock - (`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such - as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is - wrong: + (`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration + such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying + what is wrong: - one that is not such an object, or has another field; - one longer than 4 KiB; - one that names an IPv4-mapped netblock, or an address with a zone. @@ -38,8 +38,8 @@ in [`SPEC.md`](../SPEC.md)). ## Examples -With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached -as `discriminator:8080` on its network: +With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` +reached as `discriminator:8080` on its network: ```sh # Every ban. diff --git a/docs/alerts.md b/docs/alerts.md index 0f321ed..2e5f45d 100644 --- a/docs/alerts.md +++ b/docs/alerts.md @@ -77,8 +77,8 @@ on one line: } ``` -- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - one of the events above. +- `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised. + `event`: one of the events above. - `host`: the site of the request that raised the alert, since one `discriminator` serves many sites. It is left out of `source_failure`, `file_error` and `summary`. @@ -122,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>` escaped. ntfy gets the text as the message, with these headers: - `Title`: the title; -- `Authorization`: the token, as `Bearer `, while `SWW_ALERT_NTFY_TOKEN` - is set; +- `Authorization`: the token, as `Bearer `, while + `SWW_WAF_ALERT_NTFY_TOKEN` is set; - `Priority` and `Tags`: by event, as listed below. The priority and tag by event: diff --git a/docs/metrics.md b/docs/metrics.md index d17d057..df82d5d 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -6,7 +6,7 @@ keeps the `instance` label as `exported_instance` unless the scrape sets `honor_labels: true`. `rproxy` has no metrics. Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts -body bytes, never headers, whatever `SWW_BYTES_COUNT` says. +body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says. ## Traffic @@ -33,7 +33,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says. ## Countries and AS numbers -For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`: +For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`: - `sww_country_requests_total`, `sww_country_request_bytes_total`, `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by diff --git a/docs/request-log.md b/docs/request-log.md index bafd9c0..b72f83a 100644 --- a/docs/request-log.md +++ b/docs/request-log.md @@ -44,7 +44,7 @@ Every one of these lines has: - `time`: when `discriminator` had read the question's body, RFC 3339 with milliseconds, in UTC. The `report` line gives the same time as the `decision` line. -- `instance`: `SWW_INSTANCE_NAME`. +- `instance`: `SWW_WAF_INSTANCE_NAME`. - `client_ip`: the visitor's address, as `rproxy` worked it out. - `method`, `path` and `query`: the visitor's request. - `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of @@ -82,8 +82,8 @@ Every one of these lines has: - GeoJS had not answered by the time `discriminator` answered. - `content_type`: the visitor's `Content-Type`. - `content_length`: the body length the visitor announced. -- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in - lower case. +- `request_headers`: the headers listed in `SWW_WAF_LOG_REQUEST_HEADERS`, by + name in lower case. - `has_authorization` and `has_cookie`: true when the request had those headers. Their values are never logged. @@ -96,8 +96,8 @@ Every one of these lines has: - `action`: what was done: - `forward`: answered `allow`, so that `rproxy` passes it to the app; - - `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with - the action `deny`, or for a tag in `SWW_TAG_DENY`; + - `client_denied`: refused for `SWW_WAF_DENY_NETS`, for a reputation source + with the action `deny`, or for a tag in `SWW_WAF_TAG_DENY`; - `temporarily_banned`: refused under a ban that runs out, or for a `ban` rule, a trap path or a CrowdSec decision that made such a ban; - `permanently_banned`: refused under a ban that never runs out, including a @@ -114,21 +114,23 @@ Every one of these lines has: is left out when none did. - `heuristic_figures`: an object of the figures that heuristic acted on. Each heuristic and its figures: - - `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in; + - `deny_nets`: `netblock`, the entry of `SWW_WAF_DENY_NETS` the client is + in; - `active_ban`: `netblock`, the ban's netblock, and `ban_expires`; - `countries`: `country`, and `setting`, the country list that refused it; - - `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries; + - `tag_deny`: `tag`, the tag of `SWW_WAF_TAG_DENY` the request carries; - `blocklists`: `lists`, the blocklist URLs that list the client; - `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the decision ends; - `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys masked; - - `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`; + - `abuseipdb`: `score`, and `min_score`, `SWW_WAF_ABUSEIPDB_MIN_SCORE`; - `rate_limits`: `window`, `count` and `limit`; - `trap_paths`: `path`, the trap path asked for; - `rule_files`: `rule_id` and `rule_action`, of the rule that refused it; - `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`; - - `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`; + - `core_rule_set`: `score`, and `threshold`, + `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`; - `refusal_limit`: `window`, `count` and `limit`, of the error burst; - `byte_limits`: `window`, `count` and `limit`, the count in bytes. - `after_answer_heuristic`: the heuristic that banned the client after @@ -149,7 +151,7 @@ Every one of these lines has: holds: - `minute`, `hour` and `day`: requests; - `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes - `SWW_BYTES_COUNT` names, in that window. + `SWW_WAF_BYTES_COUNT` names, in that window. A count can have a fraction (see "Counting method" in [`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its @@ -159,7 +161,7 @@ Every one of these lines has: - `limit_hit`: the limit broken: - `minute`, `hour` or `day` for a rate limit; - `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; - - `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`). + - `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`). When the bytes counted for a request and its response break a limit, the request has been passed on whole, so its `action` stays `forward`. Only its @@ -177,7 +179,7 @@ Every one of these lines has: - `tags`: the request's tags, sorted, left out when it has none. Each tag and where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md). - `routewatch_tags_missing`: why the request got no tags from routewatch, - present only while `SWW_ROUTEWATCH_URL` is set: + present only while `SWW_WAF_ROUTEWATCH_URL` is set: - `no_answer_yet`: routewatch has not answered about this client yet; - `request_failed`: a query to routewatch failed within the last minute, and the process log says why; @@ -234,13 +236,13 @@ these meanings where they differ: was sooner. It includes the wait for `discriminator`. - `duration_response_ms`: milliseconds from then until the end of the answer. - `disposition`: what was done with the request: `allow`, `deny` or `close`. It - is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is - left out when: + is `discriminator`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. + It is left out when: - `discriminator` was not asked, as for the health check; - the visitor went away while `discriminator` was being asked; - `discriminator`'s answer had no valid `X-Sww-Disposition`; - `rproxy` could not pass on `discriminator`'s answer, and answered `502`. -- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave +- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave `disposition`, and otherwise left out. For example: diff --git a/docs/settings.md b/docs/settings.md index 18b7dee..aa71f68 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings" in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings that take `off`, the `_FILE` form, and what each group does and why. -The settings that start with `SWW_` are `discriminator`'s. Those that start with -`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses -between the two (see "The image's command" below). +The settings that start with `SWW_WAF_` are `discriminator`'s. Those that start +with `SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` +chooses between the two (see "The image's command" below). ## Forms of values @@ -28,97 +28,98 @@ between the two (see "The image's command" below). ## Core -- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a - port, such as `:9000`. Leave the host out (see "Ports" in +- `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` + and a port, such as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)). -- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name - in each line `discriminator` logs, and in each alert and metric, such as +- `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance` + name in each line `discriminator` logs, and in each alert and metric, such as `fsn1/discriminator`. Valid UTF-8. No control character while - `SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a - space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not. -- `SWW_MODE` (default `enforce`): `enforce` or `observe`. -- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's - group, from 32 to 128. -- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole - number above zero. -- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator` + `SWW_WAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a + space while `SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME` + is not. +- `SWW_WAF_MODE` (default `enforce`): `enforce` or `observe`. +- `SWW_WAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 + client's group, from 32 to 128. +- `SWW_WAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a + whole number above zero. +- `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `discriminator` keeps, a whole number above zero. ## Tokens -- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and +- `SWW_WAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and `/_sww/clients/`. At least 32 characters. -- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least - 32 characters. +- `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At + least 32 characters. ## State files -- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an - absolute path that exists and can be written. -- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be +- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state + files, an absolute path that exists and can be written. +- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be written after a ban is made, lifted or made permanent. A duration. -- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - written. A duration. +- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file + is written. A duration. ## Logging -- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own - messages that are written: `debug`, `info`, `warn` or `error`. -- `SWW_LOG_REQUEST_HEADERS` (default +- `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s + own messages that are written: `debug`, `info`, `warn` or `error`. +- `SWW_WAF_LOG_REQUEST_HEADERS` (default `accept,accept-language,accept-encoding,content-type,origin,range`): the request headers recorded in the log line, in either case. `Host` and `Transfer-Encoding` are refused; the host is the field `host`. -- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every +- `SWW_WAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`, each with a host and a port and nothing more. -- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that - a `syslog+tls` server's certificate must chain to, instead of the host's own. - A file that cannot be read, or holds no certificate, is refused. -- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait - to be sent, a whole number above zero. -- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, +- `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates + that a `syslog+tls` server's certificate must chain to, instead of the host's + own. A file that cannot be read, or holds no certificate, is refused. +- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they + wait to be sent, a whole number above zero. +- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `authpriv`, `ftp`, or `local0` to `local7`. -- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each - syslog record, 1 to 48 printable ASCII characters without a space. +- `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name + in each syslog record, 1 to 48 printable ASCII characters without a space. ## Metrics -- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries - get series of their own. A whole number above zero. +- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many + countries get series of their own. A whole number above zero. ## Static lists -- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring - (see step 4 in [`SPEC.md`](../SPEC.md)). -- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are +- `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your + monitoring (see step 4 in [`SPEC.md`](../SPEC.md)). +- `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are exempt from rate limiting and the byte limits. -- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step - 4 in [`SPEC.md`](../SPEC.md)). +- `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see + step 4 in [`SPEC.md`](../SPEC.md)). ## Request rate limits, per client The request past one of these limits is refused, and the client is banned. -- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - client may make in a minute. A count or `off`. The default leaves room for a +- `SWW_WAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests + a client may make in a minute. A count or `off`. The default leaves room for a person opening two pages at once with an empty cache, up to 60 files each. -- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may +- `SWW_WAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may make in an hour. A count or `off`. It is the main limit against scrapers. A person reading a site for an hour, a page every 20 seconds, makes about 900. A scraper making one request every 2 seconds makes 1,800 an hour, more than the limit. -- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make - in a day. A count or `off`. The default is four hours at the hour limit. A - scraper making one request every 10 seconds makes 8,640 a day, more than the +- `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may + make in a day. A count or `off`. The default is four hours at the hour limit. + A scraper making one request every 10 seconds makes 8,640 a day, more than the limit. - A CI runner asks its code forge for work all day and can pass the limits. Put - the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a + the runners' addresses in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a network where many people share one address, such as an office. - Counting cannot catch a client that stays under all three limits (see "Request rate limits, per client" in [`SPEC.md`](../SPEC.md)). -- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate - limiting and the byte limits, such as `/assets/`. +- `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from + rate limiting and the byte limits, such as `/assets/`. ## Byte limits, per client @@ -128,236 +129,240 @@ counted for a request and its response are added to the client's total once the response has ended. The request that takes a client's total past a limit is not cut off; the client is banned. -- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR` - (default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes - counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says - which bytes count: response bodies, request bodies or both. The request that - passes the limit is not cut off, and the client is banned when its response - ends. -- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly - thresholds count: `response`, the app's response bodies; `request`, the - visitors' request bodies; or `both`. What the visitor sent over a WebSocket - counts as request bytes, and what the app sent as response bytes. +- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`), + `SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and + `SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a + client in each window, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes + count: response bodies, request bodies or both. The request that passes the + limit is not cut off, and the client is banned when its response ends. +- `SWW_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte + anomaly thresholds count: `response`, the app's response bodies; `request`, + the visitors' request bodies; or `both`. What the visitor sent over a + WebSocket counts as request bytes, and what the app sent as response bytes. ## Lookup of AS number and country -- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - the start while a setting that needs a lookup is set: - - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - - `SWW_ADD_LOOKUP_HEADERS`; - - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an - `asn=` tag while `SWW_ROUTEWATCH_URL` is unset; - - the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`; - - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; +- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` + stops the start while a setting that needs a lookup is set: + - `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`; + - `SWW_WAF_ADD_LOOKUP_HEADERS`; + - `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag, + or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset; + - the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`; + - `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`; - an anomaly threshold per AS number. -- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `.mmdb` form, for `SWW_LOOKUP_SOURCE=file`. -- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its +- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in + its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`. +- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its client's first answer from GeoJS, and how long a request to GeoJS may take. A duration; never `off`. -- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app - the client's AS number in `X-Client-ASN` and its country in +- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the + app the client's AS number in `X-Client-ASN` and its country in `X-Client-Country`. ## Country list -- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only +- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only countries whose clients get through, such as `us,de`. A country whose - `country=` tag is in `SWW_TAG_DENY` stops the start. + `country=` tag is in `SWW_WAF_TAG_DENY` stops the start. ## Request tags Every request gets tags from what `discriminator` knows of its client. See "Request tags" in [`SPEC.md`](../SPEC.md). -- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as +- `SWW_WAF_TAG_LISTS` (default empty): address lists that each add a tag, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or `https` URL fetched like a blocklist. A name or URL listed twice is refused. - So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`, - or the CrowdSec decision list. -- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as + So is a URL that is also in `SWW_WAF_BLOCKLIST_URLS`, + `SWW_WAF_ASN_LIMIT_PERCENT_URL`, or the CrowdSec decision list. +- `SWW_WAF_TAG_DENY` (default empty): tags whose requests are refused, such as `torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a `country=` tag without a country, is refused. -- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as - tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from - `1` to `99`, of every rate limit and byte limit. The lowest percentage among a - client's tags applies. A tag listed twice is refused. So is +- `SWW_WAF_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's + limits, as tag and percentage pairs, such as `vpn:50,asn=14061:25`. A + percentage is from `1` to `99`, of every rate limit and byte limit. The lowest + percentage among a client's tags applies. A tag listed twice is refused. So is `exploitsignature`: a request gets that tag after the rate limits have counted it. -- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a +- `SWW_WAF_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a service that gives an address's announced netblock and origin AS. `discriminator` asks it about each client, in the background, and adds the tags `asn=` and `prefix=` from its answer. While it is unset, nothing asks routewatch and no request gets a `prefix=` tag. -`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag -that no request can get stops the start. A request can get only these: +`SWW_WAF_TAG_DENY` and `SWW_WAF_TAG_LIMIT_PERCENT` accept tags in either case. A +tag that no request can get stops the start. A request can get only these: -- a name in `SWW_TAG_LISTS`; +- a name in `SWW_WAF_TAG_LISTS`; - an `asn=` or `country=` tag; -- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches - only that exact announced netblock, such as `prefix=192.0.2.0/24`; -- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key +- a `prefix=` tag with a netblock, while `SWW_WAF_ROUTEWATCH_URL` is set. It + matches only that exact announced netblock, such as `prefix=192.0.2.0/24`; +- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key masked as the `reputation` field shows it; -- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set; -- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is - `true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is - `off`. +- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set; +- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED` + is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor + `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`. `crowdsec` stops the start in both settings, because a CrowdSec decision refuses the request first. ## Biased thresholds -- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a - percentage of the byte limits alone, for the AS numbers or countries listed, - such as `AS14061:50` or `cn:25`. It takes the place of the percentage - `SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or - country listed twice is refused. -- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - lookup cannot place. -- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - text file with one AS number and percentage on each line, such as - `AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. - A URL that is also in `SWW_BLOCKLIST_URLS` is refused. +- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default + empty): a percentage of the byte limits alone, for the AS numbers or countries + listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage + `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number + or country listed twice is refused. +- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client + the lookup cannot place. +- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of + a text file with one AS number and percentage on each line, such as + `AS64496:50`. Each entry counts like an `asn=` tag in + `SWW_WAF_TAG_LIMIT_PERCENT`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is + refused. ## Attack detection -- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files. -- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file. -- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as +- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule + files. +- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file. +- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. -- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`. -- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, - from 1 to 4. -- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a - match. A whole number above zero, or `off`. -- `SWW_WAF_DISABLED_RULES` (default +- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`. +- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's + paranoia level, from 1 to 4. +- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a + request is a match. A whole number above zero, or `off`. +- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default `920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules to switch off. Each a whole number above zero. An id from 900000 to 900999 is refused: those are the rules that set the Core Rule Set up, and `smallwebwaf`'s own. -- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - not inspect. -- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the - Core Rule Set reads, a size of at most `64K`. A question carries no more of a - body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of - it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). -- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set - exclusions, written as Coraza directives. It is loaded before the Core Rule - Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude - with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be - read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a - file that names a number from 900000 to 900999 outside a comment, alone or in - a range. So does a directive that removes or changes rules as the file loads, - such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded - yet. It has no `_FILE` form. -- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get - in a minute from a rule file, a trap path or the Core Rule Set, a count or +- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core + Rule Set does not inspect. +- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a + request body the Core Rule Set reads, a size of at most `64K`. A question + carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64 + KiB less one byte of it, whatever the value (see "The answer" in + [`SPEC.md`](../SPEC.md)). +- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule + Set exclusions, written as Coraza directives. It is loaded before the Core + Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that + exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that + cannot be read or does not parse stops the start, whatever + `SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from + 900000 to 900999 outside a comment, alone or in a range. So does a directive + that removes or changes rules as the file loads, such as `SecRuleRemoveById`, + since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form. +- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may + get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client (see "Attack detection" in `SPEC.md`). ## Ban lengths, response and scope -- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. -- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A - duration. -- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends +- `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. +- `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. + A duration. +- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends within which breaking a limit again lengthens the next ban. A duration. -- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one - that would be longer is permanent. A duration. -- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of - attack. A duration. -- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client +- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; + one that would be longer is permanent. A duration. +- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign + of attack. A duration. +- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client covers: the prefix length of its netblock, from 0 to 32. ## Reputation -- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as +- `SWW_WAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as `https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL without a user or a fragment. A URL listed twice is refused. -- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last - try before it is fetched again. A duration of `1h` or more. -- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:` or `log`. -- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as +- `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its + last try before it is fetched again. A duration of `1h` or more. +- `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:` or + `log`. +- `SWW_WAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as `dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus zone carries its key, as in `.xbl.dq.spamhaus.net`. A zone listed twice is refused, whatever its case or key. -- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, - such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional - port, 53 when none is given. Unset, the host's resolver is used. -- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A +- `SWW_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones + through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an + optional port, 53 when none is given. Unset, the host's resolver is used. +- `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A control character in it is refused. -- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, - from 0 to 100, that is a hit. -- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a - whole number above zero. AbuseIPDB's free accounts may make 1,000. -- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine - you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a - user or a fragment; its decision list is at `v1/decisions` under it. Set - without the key, it is refused. So is a decision list URL that is also in - `SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`. -- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for +- `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence + score, from 0 to 100, that is a hit. +- `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a + day, a whole number above zero. AbuseIPDB's free accounts may make 1,000. +- `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec + engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL + without a user or a fragment; its decision list is at `v1/decisions` under it. + Set without the key, it is refused. So is a decision list URL that is also in + `SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`. +- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the URL, it is refused. -- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:` or +- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:` or `log`, for DNS blocklist and AbuseIPDB hits. -- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or +- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or AbuseIPDB's score is used. A duration. -- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check - with AbuseIPDB may take. A duration. +- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a + check with AbuseIPDB may take. A duration. ## Alerting -- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert - as a JSON object. An `http` or `https` URL without a user or a fragment. -- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such - as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A - value cannot hold a comma. -- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that - receives each alert as a message, checked as the webhook URL is. -- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert, - as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the - webhook URL is. -- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control +- `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each + alert as a JSON object. An `http` or `https` URL without a user or a fragment. +- `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, + such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and + value. A value cannot hold a comma. +- `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook + that receives each alert as a message, checked as the webhook URL is. +- `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each + alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked + as the webhook URL is. +- `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control character in it is refused. -- `SWW_ALERT_EVENTS` (default +- `SWW_WAF_ALERT_EVENTS` (default `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`): the event types alerts are sent for, from those in [`alerts.md`](alerts.md) but `summary`. Any other name is refused. -- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its +- `SWW_WAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its repeats are held back. A duration or `off`. -- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A - whole number above zero, or `off`. +- `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. + A whole number above zero, or `off`. ## Anomaly thresholds Each scope has four thresholds: the most requests and the most body bytes -counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names. -Requests are counts, bytes are sizes. Each is `off` by default. The four +counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT` +names. Requests are counts, bytes are sizes. Each is `off` by default. The four thresholds per client are: -- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; -- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; -- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`; -- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`. +- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; +- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; +- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`; +- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR`. The other scopes' names have another prefix before the same four endings, such as `REQUESTS_PER_MINUTE`: -- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the +- `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the netblock's length: - - `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 + - `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 client; - - `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 + - `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 client. -- `SWW_ANOMALY_ASN_...`: per AS number. -- `SWW_ANOMALY_TOTAL_...`: the whole service. -- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole. - `SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as - `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a +- `SWW_WAF_ANOMALY_ASN_...`: per AS number. +- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service. +- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole. + `SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such + as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a name, or with a bad netblock, is refused. So is a name used twice. ## Settings given as files @@ -378,7 +383,7 @@ Then mount its directory into the container read-only, and name the file: docker run \ -v /srv/app/tokens:/etc/sww/tokens:ro \ -e SWW_COMMAND=discriminator \ - -e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ + -e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ ... ``` @@ -402,51 +407,52 @@ A few limits are fixed rather than settings: ## `discriminator`'s answer and `rproxy`'s wait -`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep -it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits -for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked -in the background (see "Answering in time" under "`discriminator`" in -[`SPEC.md`](../SPEC.md)). +`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. +Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No +question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and +AbuseIPDB are asked in the background (see "Answering in time" under +"`discriminator`" in [`SPEC.md`](../SPEC.md)). ## `rproxy` `sww rproxy` reads the settings below and no others. They are read once at -start, and an invalid value stops the start. None can be given as a file, since -`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what -they do. +start, and an invalid value stops the start. Any can be given as a file, as a +`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do. A time here is a whole number of milliseconds above zero, such as `2000`. -- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or - `https` URL with a host and an optional port, and nothing more. `rproxy` - speaks HTTP/1.1 to it. -- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for +- `SWW_RPROXY_WAF_BACKEND` (required, no default): `discriminator`'s URL. An + `http` or `https` URL with a host and an optional port, and nothing more. + `rproxy` speaks HTTP/1.1 to it. +- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `discriminator`'s answer, to a question and to a report. A time; never `off`. -- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when - `discriminator` cannot be connected to, does not answer at all, or does not - answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` - answers `503`. It does not apply to an answer `discriminator` gives, such as - an error. -- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or - `off`. -- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or - `off`. -- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to - send its request line and headers. It then also bounds, from the end of the - headers, the time until the app has been sent the whole request. A time or - `off`. -- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take, - from the end of the request to its last byte reaching the visitor. A time or - `off`. -- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`. -- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or - `https` URL with a host and an optional port, and nothing more. -- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): - the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts - nothing. A client outside these netblocks cannot claim another address. Where - other containers reach `rproxy` directly, or visitors on a private network - reach traefik, they can name another address in the header. Narrowing the - setting to traefik's own network closes that gap. +- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a + request when `discriminator` cannot be connected to, does not answer at all, + or does not answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the + app, and `deny` answers `503`. It does not apply to an answer `discriminator` + gives, such as an error. +- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A + size or `off`. +- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A + size or `off`. +- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may + take to send its request line and headers. It then also bounds, from the end + of the headers, the time until the app has been sent the whole request. A time + or `off`. +- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may + take, from the end of the request to its last byte reaching the visitor. A + time or `off`. +- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as + `:9000`. +- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An + `http` or `https` URL with a host and an optional port, and nothing more. +- `SWW_RPROXY_TRUSTED_PROXIES` (default + `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose + `X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client + outside these netblocks cannot claim another address. Where other containers + reach `rproxy` directly, or visitors on a private network reach traefik, they + can name another address in the header. Narrowing the setting to traefik's own + network closes that gap. ## The image's command @@ -458,14 +464,14 @@ check checks: `rproxy` or `discriminator` (see "Deployment" in Nothing reads these names any more: -- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. -- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. -- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. -- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. -- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are - `SWRP_MAX_REQUEST_TIME_MS`, in milliseconds. -- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are - `SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. -- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no - setting. `rproxy` closes a kept-open connection idle for 120 seconds, and - takes a request line and headers of at most 32 KiB. +- `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`. +- `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`. +- `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`. +- `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`. +- `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are + `SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds. +- `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are + `SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds. +- `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES` + have no setting. `rproxy` closes a kept-open connection idle for 120 seconds, + and takes a request line and headers of at most 32 KiB. diff --git a/docs/state-files.md b/docs/state-files.md index e88466a..f92bcaf 100644 --- a/docs/state-files.md +++ b/docs/state-files.md @@ -1,13 +1,13 @@ # State files -The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state" -in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has -a top-level `version` of 1. +The five JSON files in `SWW_WAF_STATE_DIR`, and what each holds. "Persistent +state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. +Each has a top-level `version` of 1. ## `bans.json` -Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those -`discriminator` made. Per entry: +Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of +those `discriminator` made. Per entry: - `netblock`, `start` and `expires` (`null` for a permanent ban); - `cause`: `attack`, `limit`, `admin` or `crowdsec`; @@ -94,10 +94,10 @@ GeoJS answers, one per line: ## Sizes - `clients.json`: about 1 KiB per client, about 20 MiB at the default - `SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of - disk writes a day. + `SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB + of disk writes a day. - `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at - the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to + the default `SWW_WAF_MAX_BANS`. Written every 15 minutes, a full file comes to about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB more. - `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when diff --git a/go.mod b/go.mod index 5470c26..fda7ebb 100644 --- a/go.mod +++ b/go.mod @@ -10,12 +10,14 @@ require ( github.com/maxmind/mmdbwriter v1.2.0 github.com/oschwald/maxminddb-golang/v2 v2.7.0 github.com/prometheus/client_golang v1.24.1 + github.com/spf13/viper v1.21.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/corazawaf/libinjection-go v0.3.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect @@ -25,14 +27,22 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/magefile/mage v1.17.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.23.0 // indirect diff --git a/go.sum b/go.sum index 58f2e8b..146812f 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,12 @@ github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBO github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -34,6 +38,10 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= @@ -58,8 +66,24 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index aca6557..f5daf49 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -1,12 +1,12 @@ // Package alerts sends alerts on bans, on traffic over an anomaly // threshold, on a source that fails and on a file with an error to each // destination set: to the webhook -// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON +// SWW_WAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // object" in docs/alerts.md describes, to the Slack incoming -// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the -// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within -// SWW_ALERT_COOLDOWN is held back, and so is an alert past -// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a +// webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the +// ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within +// SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past +// SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // bounded queue of each destination's own, so that a destination that is // slow or unreachable holds up neither the others nor any request. The // state is written to alerts.json and read from it by the state package. @@ -34,7 +34,7 @@ import ( "time" ) -// The events an alert is for, as SWW_ALERT_EVENTS names them. +// The events an alert is for, as SWW_WAF_ALERT_EVENTS names them. const ( // EventBan is a ban sww made. EventBan = "ban" @@ -45,7 +45,7 @@ const ( // threshold. EventAnomaly = "anomaly" // EventCoreRuleSetMatch is a request the Core Rule Set scored at or over - // SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in + // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in // detect mode. EventCoreRuleSetMatch = "core_rule_set_match" // EventReputationHit is a request whose client a blocklist, the @@ -61,14 +61,14 @@ const ( // cannot be read, or a state file that cannot be written. EventFileError = "file_error" // EventSummary is the summary sent as an hour ends: of the alerts held - // back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held + // back in it past SWW_WAF_ALERT_MAX_PER_HOUR, and of the repeats held // back by the cooldowns dropped as it ends, which no alert let through - // has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for - // those repeats. SWW_ALERT_EVENTS does not name it. + // has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for + // those repeats. SWW_WAF_ALERT_EVENTS does not name it. EventSummary = "summary" ) -// Events returns every event SWW_ALERT_EVENTS can name, which is its +// Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its // default. func Events() []string { return []string{ @@ -80,12 +80,12 @@ func Events() []string { // The destinations alerts are sent to, as the metrics and alerts.json // name them. const ( - // DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names. + // DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names. DestinationWebhook = "webhook" // DestinationSlack is the Slack incoming webhook - // SWW_ALERT_SLACK_WEBHOOK_URL names. + // SWW_WAF_ALERT_SLACK_WEBHOOK_URL names. DestinationSlack = "slack" - // DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names. + // DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names. DestinationNtfy = "ntfy" ) @@ -121,26 +121,26 @@ var ( // NtfyURL set, no alert is sent. type Params struct { // WebhookURL is where each alert is posted as JSON - // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders - // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). + // (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders + // are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS). WebhookURL *url.URL WebhookHeaders http.Header // SlackURL is the Slack incoming webhook each alert is posted to as a - // message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. + // message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. SlackURL *url.URL // NtfyURL is the ntfy topic each alert is published to - // (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless - // empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN). + // (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless + // empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN). NtfyURL *url.URL NtfyToken string - // Events are the events alerts are sent for (SWW_ALERT_EVENTS). + // Events are the events alerts are sent for (SWW_WAF_ALERT_EVENTS). Events []string // Cooldown is how long a repeat of an alert is held back - // (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts - // sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit. + // (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts + // sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit. Cooldown time.Duration MaxPerHour int - // Instance is SWW_INSTANCE_NAME, which every alert gives. + // Instance is SWW_WAF_INSTANCE_NAME, which every alert gives. Instance string // Now tells the time of an alert, normally time.Now in UTC. Now func() time.Time @@ -301,17 +301,17 @@ func New(params Params) *Queue { } if params.WebhookURL != nil { - q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL", + q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL", params.WebhookURL, q.webhookMessage) } if params.SlackURL != nil { - q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL", + q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL", params.SlackURL, slackMessage) } if params.NtfyURL != nil { - q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL", + q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL", params.NtfyURL, q.ntfyMessage) } @@ -319,7 +319,7 @@ func New(params Params) *Queue { } // Raise sends alert, which names its event and what is particular to it, -// unless no destination is set or SWW_ALERT_EVENTS leaves its event +// unless no destination is set or SWW_WAF_ALERT_EVENTS leaves its event // out. It gives alert the instance and the time. An alert that repeats // the last one let through less than Cooldown before is held back and // counted. The next one let through gives that count, unless an hour of @@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) { } // WouldSend reports whether Raise would let an alert for event on -// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses +// netblock through now: a destination is set, SWW_WAF_ALERT_EVENTS chooses // event, no alert for event on netblock was let through less than // Cooldown before, and fewer than MaxPerHour alerts have been let through // in the hour under way. Unlike Raise, it counts nothing. @@ -608,13 +608,13 @@ func (q *Queue) endHour(now time.Time) { if heldBack > 0 { reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+ - "past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack, + "past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack, q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour)) } if repeats > 0 { reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+ - "SWW_ALERT_COOLDOWN that no later alert gives", repeats)) + "SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats)) } if len(reasons) > 0 { diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index f903940..4cb6d88 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) { if q.WouldSend(alerts.EventBan, netblock(1)) || q.WouldSend(alerts.EventPermanentBan, netblock(1)) { - t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out") + t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out") } if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) { @@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) { onlyNtfy.NtfyURL = parseURL(ntfyURL) for setting, params := range map[string]alerts.Params{ - "SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack, - "SWW_ALERT_NTFY_URL": onlyNtfy, + "SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack, + "SWW_WAF_ALERT_NTFY_URL": onlyNtfy, } { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { t.Errorf("a ban alert would not be sent with only %s set", setting) @@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) { "as_name": "", "country": "", "reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " + - "past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows", + "past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", "detail": map[string]any{ "hour": started, "count": float64(3), @@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) { wantCounts(t, q, 0, int64(len(want)), 0, 0) if !strings.Contains(log.String(), - `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) { + `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) { t.Errorf("process log %q names no failure", log.String()) } @@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) { wantCounts(t, q, 1, 3, 0, 1) if !strings.Contains(log.String(), - `"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) { + `"msg":"gave up an alert SWW_WAF_ALERT_WEBHOOK_URL refused"`) { t.Errorf("process log %q names no alert given up", log.String()) } }) @@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) { logged := log.String() if !strings.Contains(logged, - `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) || + `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) || strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") { t.Errorf("process log %q names no failure, or names the URL", logged) } @@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) { } const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " + - "past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + - "by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" + "past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + + "by SWW_WAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary) wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary) @@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) { logged := log.String() if !strings.Contains(logged, - `"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) || + `"msg":"sending an alert to SWW_WAF_ALERT_SLACK_WEBHOOK_URL failed"`) || strings.Contains(logged, "hooks.slack.example") || strings.Contains(logged, "T0123") { t.Errorf("process log %q names no failure, or names the URL", logged) @@ -1254,7 +1254,7 @@ type eventMessage struct { title, priorityAndTag, text string } -// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS +// anAlertForEachEvent returns an alert for each event SWW_WAF_ALERT_EVENTS // names, the first in observe mode, each with its message. func anAlertForEachEvent() []eventMessage { return []eventMessage{ diff --git a/internal/anomaly/anomaly.go b/internal/anomaly/anomaly.go index 3e7c3a8..de1f97f 100644 --- a/internal/anomaly/anomaly.go +++ b/internal/anomaly/anomaly.go @@ -28,16 +28,16 @@ const maxCounters = 20000 // alerts name them. const ( // ScopeClient is one client: an IPv4 address, or an IPv6 netblock of - // SWW_IPV6_GROUP_PREFIX. + // SWW_WAF_IPV6_GROUP_PREFIX. ScopeClient = "client" - // ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX - // or SWW_ANOMALY_NET_V6_PREFIX long. + // ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX + // or SWW_WAF_ANOMALY_NET_V6_PREFIX long. ScopeNet = "net" // ScopeASN is an AS number. ScopeASN = "asn" // ScopeTotal is the whole service. ScopeTotal = "total" - // ScopeWatchedNet is a named netblock of SWW_WATCH_NETS. + // ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS. ScopeWatchedNet = "watched_net" ) @@ -62,7 +62,7 @@ type Thresholds struct { BytesPerHour int64 } -// NamedNetblock is a netblock SWW_WATCH_NETS names. +// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names. type NamedNetblock struct { Name string Netblock netip.Prefix @@ -70,14 +70,14 @@ type NamedNetblock struct { // Params are what New needs. type Params struct { - // The thresholds of each scope: SWW_ANOMALY_CLIENT_*, - // SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and - // SWW_WATCH_*. + // The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*, + // SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and + // SWW_WAF_WATCH_*. Client, Net, ASN, Total, Watch Thresholds // NetV4Prefix and NetV6Prefix are the lengths of the netblock around a - // client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX). + // client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX). NetV4Prefix, NetV6Prefix int - // NamedNetblocks are SWW_WATCH_NETS. + // NamedNetblocks are SWW_WAF_WATCH_NETS. NamedNetblocks []NamedNetblock // Alerts receive the anomaly alerts. Alerts *alerts.Queue @@ -107,13 +107,13 @@ type Request struct { Host string // Client is the client's address, and ClientGroup the client it is // counted as: its IPv4 address, or the IPv6 netblock of - // SWW_IPV6_GROUP_PREFIX its address is in. + // SWW_WAF_IPV6_GROUP_PREFIX its address is in. Client netip.Addr ClientGroup netip.Prefix // ASN, ASName and Country are the client's as looked up, each "" when // unknown. ASN, ASName, Country string - // Bytes are the request's bytes, as SWW_BYTES_COUNT counts them. + // Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them. Bytes int64 } diff --git a/internal/bans/bans.go b/internal/bans/bans.go index 7b132f6..54df9c8 100644 --- a/internal/bans/bans.go +++ b/internal/bans/bans.go @@ -123,7 +123,7 @@ type Notes struct { LimitPercentSetting string `json:"limit_percent_setting,omitempty"` // RuleID and Target are, for a ban for a clear sign of attack, the id // of the rule file rule that matched, and its target; TrapPath is, for - // one for a request for a path in SWW_TRAP_PATHS, that path as the + // one for a request for a path in SWW_WAF_TRAP_PATHS, that path as the // client sent it. RuleID string `json:"rule_id,omitempty"` Target string `json:"target,omitempty"` @@ -199,7 +199,7 @@ type Ledger struct { // v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6 // netblocks that have been banned. Check looks for a ban at each of // them, so that a ban read from bans.json refuses every client in its - // netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX, + // netblock even when it was made with another SWW_WAF_BAN_SCOPE_V4_PREFIX, // or another length of an IPv6 client's netblock. v4Lengths, v6Lengths []int } diff --git a/internal/config/config.go b/internal/config/config.go index 8cb82d1..713562d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,9 @@ // Package config reads sww's settings, and is the one place they are // read. Every setting is an environment variable. The settings of `sww -// discriminator` start with SWW_, every one of them has a default, and most can be -// given as a file instead. Those of `sww rproxy` start with SWRP_. Each -// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file, -// since rproxy never touches disk. SWW_COMMAND chooses which of the two -// the image runs. +// discriminator` start with SWW_WAF_, every one of them has a default, and most can be +// given as a file instead. Those of `sww rproxy` start with SWW_RPROXY_. Each +// of them but SWW_RPROXY_WAF_BACKEND has a default, and each can be given as a file +// too. SWW_COMMAND chooses which of the two the image runs. package config import ( @@ -27,6 +26,8 @@ import ( "unicode" "unicode/utf8" + "github.com/spf13/viper" + "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/remotelog" @@ -35,121 +36,121 @@ import ( // Config is the settings of `sww discriminator`. A time or size limit of // zero is off. type Config struct { - // ListenAddr is where discriminator listens (SWW_LISTEN_ADDR). + // ListenAddr is where discriminator listens (SWW_WAF_LISTEN_ADDR). ListenAddr string // InstanceName is the name every log line and alert gives as instance, - // and every metric carries as its label instance (SWW_INSTANCE_NAME), + // and every metric carries as its label instance (SWW_WAF_INSTANCE_NAME), // by default the host's name, which docker sets to the first 12 // characters of the container's id. InstanceName string - // Observe is true in observe mode, when SWW_MODE is observe rather - // than enforce: a request that SWW_DENY_NETS, a ban, the country + // Observe is true in observe mode, when SWW_WAF_MODE is observe rather + // than enforce: a request that SWW_WAF_DENY_NETS, a ban, the country // lists, a rate limit, a rule or the Core Rule Set would refuse is // allowed instead, and no ban is made. Observe bool // IPv6GroupPrefix is the length of the IPv6 netblock that is one client - // (SWW_IPV6_GROUP_PREFIX), from 32 to 128. + // (SWW_WAF_IPV6_GROUP_PREFIX), from 32 to 128. IPv6GroupPrefix int // MaxTrackedClients is the most clients the table of clients holds, in - // memory and in clients.json (SWW_MAX_TRACKED_CLIENTS). + // memory and in clients.json (SWW_WAF_MAX_TRACKED_CLIENTS). MaxTrackedClients int // AllowNets are the netblocks whose clients skip every check - // (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the - // rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS). + // (SWW_WAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the + // rate limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_NETS). // DenyNets are those whose clients are always refused - // (SWW_DENY_NETS). + // (SWW_WAF_DENY_NETS). AllowNets []netip.Prefix RateLimitExemptNets []netip.Prefix DenyNets []netip.Prefix // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the // most requests a client may make in a minute, an hour and a day - // (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and - // SWW_RATE_LIMIT_PER_DAY). + // (SWW_WAF_RATE_LIMIT_PER_MINUTE, SWW_WAF_RATE_LIMIT_PER_HOUR and + // SWW_WAF_RATE_LIMIT_PER_DAY). RateLimitPerMinute int64 RateLimitPerHour int64 RateLimitPerDay int64 // RateLimitExemptPaths are the path prefixes whose requests the rate - // limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS). + // limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_PATHS). // Each starts with /. RateLimitExemptPaths []string // BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the // most bytes a client's requests may carry in a minute, an hour and a - // day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and - // SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count - // toward them (SWW_BYTES_COUNT): response, request or both. + // day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and + // SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count + // toward them (SWW_WAF_BYTES_COUNT): response, request or both. BytesLimitPerMinute int64 BytesLimitPerHour int64 BytesLimitPerDay int64 BytesCount string // LookupSource is where each client's AS number and country are - // looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere. + // looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. // LookupDBPath is the lookup database, the IPinfo Lite file looked up - // in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any + // in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any // other source. A request waits up to LookupTimeout for its client's // first answer from GeoJS while a setting needs it - // (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true + // (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true // when the app is passed the client's AS number and country in headers - // (SWW_ADD_LOOKUP_HEADERS). + // (SWW_WAF_ADD_LOOKUP_HEADERS). LookupSource string LookupDBPath string LookupTimeout time.Duration AddLookupHeaders bool // ExclusivelyAllowedCountries, when not empty, are the only countries - // whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES), + // whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES), // two-letter codes in capitals, as GeoJS gives them. ExclusivelyAllowedCountries []string // TagLists are the address lists that each add a tag to the requests of - // the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose - // requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients + // the clients they hold (SWW_WAF_TAG_LISTS). TagDeny are the tags whose + // requests are refused (SWW_WAF_TAG_DENY). TagLimitPercent gives the clients // with a tag that percentage of every rate limit and byte limit, from 1 - // to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in + // to 99 (SWW_WAF_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in // lower case, a country code in capitals. TagLists []TagList TagDeny []string TagLimitPercent map[string]int64 // RouteWatchURL is where the routewatch instance is that adds the asn= - // and prefix= tags, "" for none (SWW_ROUTEWATCH_URL). + // and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL). RouteWatchURL string // The biased thresholds. ASNBytesPercent and CountryBytesPercent give // those they list a percentage of the byte limits in place of the one // TagLimitPercent gives their asn= or country= tag - // (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds + // (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds // percentages from 0 to 100, by AS number, written as AS64496, or by // country, a two-letter code in capitals, as the lookup gives them. // UnknownLimitPercent is the percentage of every limit a client without - // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is + // a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is // where a file of AS:percent lines is fetched from, whose percentages - // count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL), + // count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL), // "" while it is unset. ASNBytesPercent map[string]int64 CountryBytesPercent map[string]int64 UnknownLimitPercent int64 ASNLimitPercentURL string // BlocklistURLs are where the blocklists are fetched from - // (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is + // (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is // fetched again BlocklistRefresh after it was last fetched or tried - // (SWW_BLOCKLIST_REFRESH), which is never less than an hour. + // (SWW_WAF_BLOCKLIST_REFRESH), which is never less than an hour. // BlocklistAction is what is done with a client a blocklist lists - // (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit, + // (SWW_WAF_BLOCKLIST_ACTION): deny, limit or log; for limit, // BlocklistLimitPercent is the percentage of every limit it gets. BlocklistURLs []string BlocklistRefresh time.Duration BlocklistAction string BlocklistLimitPercent int64 // DNSBLZones are the DNSBL zones clients are asked about - // (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or + // (SWW_WAF_DNSBL_ZONES), through DNSBLResolver (SWW_WAF_DNSBL_RESOLVER), or // the host's resolver while that is the zero AddrPort. // AbuseIPDBKey is the key of the AbuseIPDB account clients are checked - // with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score - // of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and + // with (SWW_WAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score + // of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and // at most AbuseIPDBDailyBudget checks are made a day - // (SWW_ABUSEIPDB_DAILY_BUDGET). + // (SWW_WAF_ABUSEIPDB_DAILY_BUDGET). // ReputationAction is what is done with a client a zone's verdict lists, - // or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log; + // or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log; // for limit, ReputationLimitPercent is the percentage of every limit it // gets. A verdict or a score is used for ReputationCacheTTL after it was - // fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take - // ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off. + // fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take + // ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off. DNSBLZones []string DNSBLResolver netip.AddrPort AbuseIPDBKey string @@ -160,74 +161,74 @@ type Config struct { ReputationCacheTTL time.Duration ReputationTimeout time.Duration // CrowdSecDecisionsURL is where the decision list of the CrowdSec - // engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from: + // engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from: // that URL with v1/decisions added to its path, "" while it is unset and // none is. CrowdSecKey is the key the engine is asked with - // (SWW_CROWDSEC_LAPI_KEY). + // (SWW_WAF_CROWDSEC_LAPI_KEY). CrowdSecDecisionsURL string CrowdSecKey string // BanResponse is the status a refused client is answered with, 403 // or 429, or 0 to close the connection without an answer - // (SWW_BAN_RESPONSE). It answers a banned client, a request that - // breaks a rate limit or matches a ban rule, SWW_DENY_NETS, - // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY. + // (SWW_WAF_BAN_RESPONSE). It answers a banned client, a request that + // breaks a rate limit or matches a ban rule, SWW_WAF_DENY_NETS, + // SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_WAF_TAG_DENY. BanResponse int // LimitBanDuration is the ban for a first broken rate limit - // (SWW_LIMIT_BAN_DURATION). A limit broken again within + // (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within // LimitBanRepeatWindow after the last ban ended bans for three times - // as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that + // as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that // would be longer than MaxBanDuration is permanent instead - // (SWW_MAX_BAN_DURATION). None of them can be off. + // (SWW_WAF_MAX_BAN_DURATION). None of them can be off. LimitBanDuration time.Duration LimitBanRepeatWindow time.Duration MaxBanDuration time.Duration // AttackBanDuration is the ban for a first clear sign of attack - // (SWW_ATTACK_BAN_DURATION). It cannot be off. + // (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off. AttackBanDuration time.Duration - // MaxBans is the most bans held (SWW_MAX_BANS). + // MaxBans is the most bans held (SWW_WAF_MAX_BANS). MaxBans int // BanScopeV4Prefix is the length of the netblock around an IPv4 - // client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX). + // client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX). BanScopeV4Prefix int // StateDir is the directory of the state files, an absolute path - // (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban - // is made (SWW_STATE_WRITE_DELAY), and every state file every - // StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be + // (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban + // is made (SWW_WAF_STATE_WRITE_DELAY), and every state file every + // StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither can be // off. StateDir string StateWriteDelay time.Duration StateCounterInterval time.Duration // LogRequestHeaders are the request headers whose values the request - // log gives, in lower case (SWW_LOG_REQUEST_HEADERS). + // log gives, in lower case (SWW_WAF_LOG_REQUEST_HEADERS). LogRequestHeaders []string // LogLevel is the least severe of the process's own messages that are - // written (SWW_LOG_LEVEL). It holds back no request log line. + // written (SWW_WAF_LOG_LEVEL). It holds back no request log line. LogLevel slog.Level // AdminToken is the bearer token an admin sends for the ban endpoints - // and /_sww/clients/ (SWW_ADMIN_TOKEN), "" while it is + // and /_sww/clients/ (SWW_WAF_ADMIN_TOKEN), "" while it is // unset and they are off. AdminToken string // MetricsToken is the bearer token a scraper sends for the metrics - // (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off. + // (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off. // MetricsTopN is how many AS numbers and how many countries get series - // of their own in the metrics (SWW_METRICS_TOP_N). + // of their own in the metrics (SWW_WAF_METRICS_TOP_N). MetricsToken string MetricsTopN int - // RulesDir is the directory of the rule files (SWW_RULES_DIR), read - // unless RulesEnabled is false (SWW_RULES_ENABLED). + // RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read + // unless RulesEnabled is false (SWW_WAF_RULES_ENABLED). RulesDir string RulesEnabled bool - // WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff, + // WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff, // WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia - // level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and + // level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and // WAFAnomalyThreshold the anomaly score at which a request is a match - // (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules - // are the ids of its rules switched off (SWW_WAF_DISABLED_RULES), + // (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules + // are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES), // WAFExemptPaths the path prefixes it does not inspect - // (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body - // it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. + // (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body + // it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none. // CoreRuleSetExclusionsFile is the path of the file of its exclusions - // (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. + // (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. WAFMode string WAFParanoiaLevel int WAFAnomalyThreshold int @@ -236,21 +237,21 @@ type Config struct { WAFBodyLimit int64 CoreRuleSetExclusionsFile string // TrapPaths are the paths a request for which is a clear sign of - // attack (SWW_TRAP_PATHS), each starting with / and without a ?. + // attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?. TrapPaths []string // ErrorBurstThreshold is the most requests of a client within a minute // that discriminator may refuse after a rule file or Core Rule Set match; one - // more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off. + // more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off. ErrorBurstThreshold int64 // LogRemoteURL is where every line on stdout is also sent - // (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent. + // (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent. // LogRemoteTLSCAs are the certificates a syslog+tls endpoint's - // certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while + // certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while // it is unset and the host's own are used. LogRemoteBuffer is the most - // lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER). + // lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER). // LogRemoteFacility is the number of the syslog facility - // (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME - // (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records + // (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME + // (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records // the lines are sent in. LogRemoteURL *url.URL LogRemoteTLSCAs *x509.CertPool @@ -258,18 +259,18 @@ type Config struct { LogRemoteFacility int LogRemoteAppName string // AlertWebhookURL is where each alert is posted as JSON - // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders - // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). + // (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders + // are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS). // AlertSlackWebhookURL is the Slack incoming webhook each alert is - // posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and + // posted to as a message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), and // AlertNtfyURL the ntfy topic each is published to - // (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, - // unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN). + // (SWW_WAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, + // unless empty, is sent to ntfy with each (SWW_WAF_ALERT_NTFY_TOKEN). // With none of the three URLs set, no alert is sent. AlertEvents are - // the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an - // alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and + // the events alerts are sent for (SWW_WAF_ALERT_EVENTS). A repeat of an + // alert within AlertCooldown is held back (SWW_WAF_ALERT_COOLDOWN), and // so is an alert past AlertMaxPerHour in an hour, for the hour's - // summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both. + // summary (SWW_WAF_ALERT_MAX_PER_HOUR); 0 is off for both. AlertWebhookURL *url.URL AlertWebhookHeaders http.Header AlertSlackWebhookURL *url.URL @@ -279,14 +280,14 @@ type Config struct { AlertCooldown time.Duration AlertMaxPerHour int // The anomaly thresholds, which only raise alerts: the most requests - // and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*), - // per netblock around a client (SWW_ANOMALY_NET_*), per AS number - // (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*) - // and per named netblock (SWW_WATCH_*), each 0 while it is off. + // and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*), + // per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number + // (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*) + // and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off. // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the - // netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and - // SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks - // (SWW_WATCH_NETS). + // netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and + // SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks + // (SWW_WAF_WATCH_NETS). AnomalyClient anomaly.Thresholds AnomalyNet anomaly.Thresholds AnomalyASN anomaly.Thresholds @@ -305,7 +306,7 @@ type Config struct { // off. const off = "off" -// The values of SWW_WAF_MODE. +// The values of SWW_WAF_CORE_RULE_SET_MODE. const ( // WAFModeOff runs no request through the Core Rule Set. WAFModeOff = off @@ -315,8 +316,8 @@ const ( WAFModeBlock = "block" ) -// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the -// lookup database, the file SWW_LOOKUP_DB_PATH names. +// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the +// lookup database, the file SWW_WAF_LOOKUP_DB_PATH names. const fileSource = "file" const ( @@ -326,7 +327,7 @@ const ( gibibyte = 1 << 30 ipv4Bits = 32 ipv6Bits = 128 - // minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the + // minIPv6GroupPrefix is the shortest SWW_WAF_IPV6_GROUP_PREFIX, the // netblock a provider is usually given: a shorter one would make one // client of the customers of several providers. minIPv6GroupPrefix = 32 @@ -343,8 +344,8 @@ const ( // masked is what the log shows for a token that is set, and in place of // a secret in another setting. masked = "********" - // defaultListenAddr is the default of SWW_LISTEN_ADDR and - // SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL. + // defaultListenAddr is the default of SWW_WAF_LISTEN_ADDR and + // SWW_RPROXY_LISTEN_ADDR, and defaultUpstreamURL that of SWW_RPROXY_UPSTREAM_URL. defaultListenAddr = ":8080" defaultUpstreamURL = "http://127.0.0.1:8081" ) @@ -372,7 +373,7 @@ var ( errHeaderTakenOut = errors.New( "is taken out of every request by Go's HTTP server, so it can never " + "be logged") - errOnBothLists = errors.New("is in SWW_TAG_DENY too") + errOnBothLists = errors.New("is in SWW_WAF_TAG_DENY too") errNotLookupSource = errors.New("is not geojs, file or off") errNeedsLookups = errors.New("it needs each client looked up") errNeedsDBPath = errors.New("it names the file to look clients up in") @@ -448,7 +449,7 @@ var ( errNotListURL = errors.New( "is not an http or https URL without a user or a fragment, " + "such as https://www.spamhaus.org/drop/drop.txt") - errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too") + errInBlocklistURLs = errors.New("is in SWW_WAF_BLOCKLIST_URLS too") errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") errNotAction = errors.New( "is not deny, limit: such as limit:25, or log") @@ -472,9 +473,9 @@ var ( errNeedsLAPIKey = errors.New("the engine answers no request without it") errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") errAnotherList = errors.New( - "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") - errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " + - "SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too") + "is in SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too") + errFetchedOtherwise = errors.New("is in SWW_WAF_BLOCKLIST_URLS, is " + + "SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too") errNotTagList = errors.New( "is not a name, = and an http or https URL, such as " + "torexit=https://example.com/tor.txt") @@ -490,124 +491,123 @@ var ( errCrowdSecTag = errors.New( "cannot be used: a CrowdSec decision refuses the request first") errNoRequestGetsTag = errors.New( - "is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " + - "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + - "a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " + - "SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " + + "is a tag no request can get: it is not a name in SWW_WAF_TAG_LISTS, an " + + "asn= or country= tag, a dnsbl= tag of a zone in SWW_WAF_DNSBL_ZONES, " + + "a prefix= tag with SWW_WAF_ROUTEWATCH_URL set, abuseipdb with " + + "SWW_WAF_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " + "Core Rule Set on") ) -// FromEnvironment reads the settings with lookupEnv, normally -// os.LookupEnv. A setting may instead be given as a file: the variable -// named by the setting's name with _FILE added names the file, which is -// read now (see lookup). A setting that is not set takes its default. A -// setting that is set but invalid is an error that names it. +// FromEnvironment reads the settings through v, normally the viper +// NewViper returns, bound to the process's environment. A setting may +// instead be given as a file: the variable named by the setting's name +// with _FILE added names the file, which is read now (see lookup). A +// setting that is not set takes its default. A setting that is set but +// invalid is an error that names it. // //nolint:funlen // one line for each setting, a list that grows with them -func FromEnvironment( - lookupEnv func(string) (string, bool), -) (*Config, error) { - env := &environment{lookupEnv: lookupEnv} +func FromEnvironment(v *viper.Viper) (*Config, error) { + env := &environment{v: v} cfg := &Config{ - ListenAddr: env.address("SWW_LISTEN_ADDR"), + ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"), InstanceName: env.instanceName(), - Observe: env.observe("SWW_MODE", "enforce"), - IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"), - MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"), - AllowNets: env.netblocks("SWW_ALLOW_NETS", ""), - RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""), - DenyNets: env.netblocks("SWW_DENY_NETS", ""), - RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"), - RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"), - RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"), - RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""), - BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"), - BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"), - BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"), - BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"), - LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"), - LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), - LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"), - AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), + Observe: env.observe("SWW_WAF_MODE", "enforce"), + IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_WAF_IPV6_GROUP_PREFIX", "64"), + MaxTrackedClients: env.numberNotOff("SWW_WAF_MAX_TRACKED_CLIENTS", "20000"), + AllowNets: env.netblocks("SWW_WAF_ALLOW_NETS", ""), + RateLimitExemptNets: env.netblocks("SWW_WAF_RATE_LIMIT_EXEMPT_NETS", ""), + DenyNets: env.netblocks("SWW_WAF_DENY_NETS", ""), + RateLimitPerMinute: env.count("SWW_WAF_RATE_LIMIT_PER_MINUTE", "120"), + RateLimitPerHour: env.count("SWW_WAF_RATE_LIMIT_PER_HOUR", "1500"), + RateLimitPerDay: env.count("SWW_WAF_RATE_LIMIT_PER_DAY", "6000"), + RateLimitExemptPaths: env.pathPrefixes("SWW_WAF_RATE_LIMIT_EXEMPT_PATHS", ""), + BytesLimitPerMinute: env.size("SWW_WAF_BYTES_LIMIT_PER_MINUTE", "off"), + BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"), + BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"), + BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"), + LookupSource: env.lookupSource("SWW_WAF_LOOKUP_SOURCE", "geojs"), + LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""), + LookupTimeout: env.durationNotOff("SWW_WAF_LOOKUP_TIMEOUT", "800ms"), + AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"), ExclusivelyAllowedCountries: env.countries( - "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), - TagLists: env.tagLists("SWW_TAG_LISTS"), - TagDeny: env.tags("SWW_TAG_DENY"), - TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"), - RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"), - ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN), - CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry), - UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"), - ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"), - BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"), - BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"), - DNSBLZones: env.zones("SWW_DNSBL_ZONES"), - DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"), - AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"), - AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"), - AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"), - ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"), - ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"), - CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"), - CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"), - BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"), - LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"), - LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"), - MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"), - AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"), - MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"), - BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"), - StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"), - StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"), - StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"), - LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS", + "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), + TagLists: env.tagLists("SWW_WAF_TAG_LISTS"), + TagDeny: env.tags("SWW_WAF_TAG_DENY"), + TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"), + RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"), + ASNBytesPercent: env.percents("SWW_WAF_ASN_BYTES_PERCENT", ParseASN), + CountryBytesPercent: env.percents("SWW_WAF_COUNTRY_BYTES_PERCENT", parseCountry), + UnknownLimitPercent: env.percent("SWW_WAF_UNKNOWN_LIMIT_PERCENT", "100"), + ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"), + BlocklistURLs: env.listURLs("SWW_WAF_BLOCKLIST_URLS"), + BlocklistRefresh: env.refresh("SWW_WAF_BLOCKLIST_REFRESH", "24h"), + DNSBLZones: env.zones("SWW_WAF_DNSBL_ZONES"), + DNSBLResolver: env.resolver("SWW_WAF_DNSBL_RESOLVER"), + AbuseIPDBKey: env.secret("SWW_WAF_ABUSEIPDB_KEY"), + AbuseIPDBMinScore: env.percent("SWW_WAF_ABUSEIPDB_MIN_SCORE", "75"), + AbuseIPDBDailyBudget: env.numberNotOff("SWW_WAF_ABUSEIPDB_DAILY_BUDGET", "900"), + ReputationCacheTTL: env.durationNotOff("SWW_WAF_REPUTATION_CACHE_TTL", "24h"), + ReputationTimeout: env.durationNotOff("SWW_WAF_REPUTATION_TIMEOUT", "2s"), + CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_WAF_CROWDSEC_LAPI_URL"), + CrowdSecKey: env.secret("SWW_WAF_CROWDSEC_LAPI_KEY"), + BanResponse: env.banResponse("SWW_WAF_BAN_RESPONSE", "403"), + LimitBanDuration: env.durationNotOff("SWW_WAF_LIMIT_BAN_DURATION", "1h"), + LimitBanRepeatWindow: env.durationNotOff("SWW_WAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), + MaxBanDuration: env.durationNotOff("SWW_WAF_MAX_BAN_DURATION", "7d"), + AttackBanDuration: env.durationNotOff("SWW_WAF_ATTACK_BAN_DURATION", "7d"), + MaxBans: env.numberNotOff("SWW_WAF_MAX_BANS", "5000"), + BanScopeV4Prefix: env.v4Prefix("SWW_WAF_BAN_SCOPE_V4_PREFIX", "32"), + StateDir: env.absolutePath("SWW_WAF_STATE_DIR", "/var/lib/sww"), + StateWriteDelay: env.durationNotOff("SWW_WAF_STATE_WRITE_DELAY", "10s"), + StateCounterInterval: env.durationNotOff("SWW_WAF_STATE_COUNTER_INTERVAL", "15m"), + LogRequestHeaders: env.headerNames("SWW_WAF_LOG_REQUEST_HEADERS", "accept,accept-language,accept-encoding,content-type,origin,range"), - LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"), - AdminToken: env.token("SWW_ADMIN_TOKEN"), - MetricsToken: env.token("SWW_METRICS_TOKEN"), - MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"), - RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"), - RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"), - WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock), - WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"), - WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"), - WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES", + LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"), + AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"), + MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"), + MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"), + RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"), + RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"), + WAFMode: env.wafMode("SWW_WAF_CORE_RULE_SET_MODE", WAFModeBlock), + WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL", "1"), + WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", "5"), + WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES", "920340,920420,920440,920640,930130,930140"), - WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""), - WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), + WAFExemptPaths: env.pathPrefixes("SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS", ""), + WAFBodyLimit: env.wafBodyLimit("SWW_WAF_CORE_RULE_SET_BODY_LIMIT", off), CoreRuleSetExclusionsFile: env.filePath( - "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), - TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), - ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), - LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), - LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"), - LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"), - LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"), - AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"), - AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"), - AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"), - AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"), - AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"), - AlertEvents: env.alertEvents("SWW_ALERT_EVENTS", + "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"), + TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"), + ErrorBurstThreshold: env.count("SWW_WAF_ERROR_BURST_THRESHOLD", "30"), + LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"), + LogRemoteTLSCAs: env.certificates("SWW_WAF_LOG_REMOTE_TLS_CA_FILE"), + LogRemoteBuffer: env.numberNotOff("SWW_WAF_LOG_REMOTE_BUFFER", "10000"), + LogRemoteFacility: env.facility("SWW_WAF_LOG_REMOTE_FACILITY", "local0"), + AlertWebhookURL: env.webhookURL("SWW_WAF_ALERT_WEBHOOK_URL"), + AlertWebhookHeaders: env.webhookHeaders("SWW_WAF_ALERT_WEBHOOK_HEADERS"), + AlertSlackWebhookURL: env.webhookURL("SWW_WAF_ALERT_SLACK_WEBHOOK_URL"), + AlertNtfyURL: env.webhookURL("SWW_WAF_ALERT_NTFY_URL"), + AlertNtfyToken: env.secret("SWW_WAF_ALERT_NTFY_TOKEN"), + AlertEvents: env.alertEvents("SWW_WAF_ALERT_EVENTS", strings.Join(alerts.Events(), ",")), - AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"), - AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"), - AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"), - AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"), - AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"), - AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"), - AnomalyWatch: env.thresholds("SWW_WATCH_"), - AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"), - AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"), - WatchNets: env.namedNetblocks("SWW_WATCH_NETS"), + AlertCooldown: env.duration("SWW_WAF_ALERT_COOLDOWN", "15m"), + AlertMaxPerHour: env.numberOrOff("SWW_WAF_ALERT_MAX_PER_HOUR", "60"), + AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"), + AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"), + AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"), + AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"), + AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"), + AnomalyNetV4Prefix: env.v4Prefix("SWW_WAF_ANOMALY_NET_V4_PREFIX", "24"), + AnomalyNetV6Prefix: env.v6Prefix("SWW_WAF_ANOMALY_NET_V6_PREFIX", "48"), + WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"), } - cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME", + cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME", cfg.InstanceName, cfg.LogRemoteURL != nil) cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action( - "SWW_BLOCKLIST_ACTION", "deny") + "SWW_WAF_BLOCKLIST_ACTION", "deny") cfg.ReputationAction, cfg.ReputationLimitPercent = env.action( - "SWW_REPUTATION_ACTION", "limit:25") + "SWW_WAF_REPUTATION_ACTION", "limit:25") env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) env.checkLookupDBPath(cfg) @@ -629,8 +629,8 @@ func FromEnvironment( // Command reads only SWW_COMMAND, the command the image runs and its // health check checks: rproxy when it is unset or empty, or discriminator. It cannot // be given as a file. Any other value is an error that names the setting. -func Command(lookupEnv func(string) (string, bool)) (string, error) { - command, _ := lookupEnv("SWW_COMMAND") +func Command(v *viper.Viper) (string, error) { + command, _ := readSetting(v, "SWW_COMMAND") switch command { case "", "rproxy": @@ -642,22 +642,22 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) { } } -// DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a +// DiscriminatorListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a // file, as FromEnvironment does. The health check in discriminator's container needs // no other setting, so it reads no other, nor a file that another names. -func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) { - env := &environment{lookupEnv: lookupEnv} - listenAddr := env.address("SWW_LISTEN_ADDR") +func DiscriminatorListenAddr(v *viper.Viper) (string, error) { + env := &environment{v: v} + listenAddr := env.address("SWW_WAF_LISTEN_ADDR") return listenAddr, env.err } -// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a +// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a // file, as FromEnvironment does, so that the line saying a setting is // invalid carries it too. A file that cannot be read gives the default // here, and FromEnvironment then stops the start over it. -func InstanceName(lookupEnv func(string) (string, bool)) string { - env := &environment{lookupEnv: lookupEnv} +func InstanceName(v *viper.Viper) string { + env := &environment{v: v} return env.instanceName() } @@ -673,11 +673,23 @@ func (c *Config) LogValue() slog.Value { } // environment is where FromEnvironment reads the settings: it notes each -// value for the log, and keeps the first error. +// value for the log, and keeps the first error. It reads every setting +// through v, the viper NewViper returns, and honours each setting's _FILE +// form too. type environment struct { - lookupEnv func(string) (string, bool) - settings []slog.Attr - err error + v *viper.Viper + settings []slog.Attr + err error +} + +// readSetting returns a setting's value and whether it is set, reading it +// through v. +func readSetting(v *viper.Viper, name string) (string, bool) { + if !v.IsSet(name) { + return "", false + } + + return v.GetString(name), true } // value returns a setting's value, or its default when it is not set, @@ -698,10 +710,10 @@ func (e *environment) value(name, defaultValue string) string { // names, less one newline at their end. It notes that file's path for the // log. Both variables set, or a file that cannot be read, is an error. func (e *environment) lookup(name string) (string, bool) { - value, set := e.lookupEnv(name) + value, set := readSetting(e.v, name) fileName := name + "_FILE" - path, inFile := e.lookupEnv(fileName) + path, inFile := readSetting(e.v, fileName) if !inFile { return value, set } @@ -893,7 +905,7 @@ func (e *environment) percents( return percents } -// tagLists reads SWW_TAG_LISTS, which is empty by default. +// tagLists reads SWW_WAF_TAG_LISTS, which is empty by default. func (e *environment) tagLists(name string) []TagList { lists, err := parseTagLists(e.value(name, "")) e.check(name, err) @@ -1057,32 +1069,32 @@ func (e *environment) lookupSource(name, defaultValue string) string { return source } -// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without -// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source: +// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without +// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source: // one source at a time. func (e *environment) checkLookupDBPath(cfg *Config) { switch { case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": - e.check("SWW_LOOKUP_SOURCE", fmt.Errorf( - "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) + e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf( + "is file while SWW_WAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": - e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf( - "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) + e.check("SWW_WAF_LOOKUP_DB_PATH", fmt.Errorf( + "is set while SWW_WAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) } } // checkCountriesAndLookups refuses a country in -// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and, -// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked -// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name +// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_WAF_TAG_DENY, and, +// while SWW_WAF_LOOKUP_SOURCE is off, each setting that needs clients looked +// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name // an asn= or country= tag, the biased thresholds, -// SWW_ASN_LIMIT_PERCENT_URL among them, of which -// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a +// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which +// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a // limit, and the anomaly thresholds per AS number. func (e *environment) checkCountriesAndLookups(cfg *Config) { for _, country := range cfg.ExclusivelyAllowedCountries { if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) { - e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", + e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", fmt.Errorf("%q %w", country, errOnBothLists)) } } @@ -1101,77 +1113,77 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) { name string set bool }{ - {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, - {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, - {"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)}, - {"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc( + {"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, + {"SWW_WAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, + {"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)}, + {"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc( slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)}, - {"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, - {"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, - {"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, - {"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, - {"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, - {"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, - {"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, - {"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, + {"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, + {"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, + {"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, + {"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, + {"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, + {"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, + {"SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, + {"SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, } { if setting.set { - e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", + e.check(setting.name, fmt.Errorf("is set while SWW_WAF_LOOKUP_SOURCE is off; %w", errNeedsLookups)) } } } -// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a +// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a // blocklist too: the file at a URL is fetched as one list or the other. func (e *environment) checkASNLimitPercentURL(cfg *Config) { if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) { - e.check("SWW_ASN_LIMIT_PERCENT_URL", + e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL", fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs)) } } -// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without -// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list +// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without +// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list // that is fetched as another list too. func (e *environment) checkCrowdSec(cfg *Config) { decisionsURL := cfg.CrowdSecDecisionsURL switch { case decisionsURL != "" && cfg.CrowdSecKey == "": - e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) + e.check("SWW_WAF_CROWDSEC_LAPI_URL", fmt.Errorf( + "is set while SWW_WAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) case decisionsURL == "" && cfg.CrowdSecKey != "": - e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) + e.check("SWW_WAF_CROWDSEC_LAPI_KEY", fmt.Errorf( + "is set while SWW_WAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || decisionsURL == cfg.ASNLimitPercentURL): - e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", - decisionsURL, errAnotherList)) + e.check("SWW_WAF_CROWDSEC_LAPI_URL", + fmt.Errorf("gives the decision list %q, which %w", decisionsURL, errAnotherList)) } } -// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as +// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as // another list too: the file at a URL is read as one kind of list. func (e *environment) checkTagLists(cfg *Config) { for _, list := range cfg.TagLists { if slices.Contains(cfg.BlocklistURLs, list.URL) || list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL { - e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise)) + e.check("SWW_WAF_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise)) } } } -// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would +// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would // never apply: crowdsec, whose request is refused before tags are acted on, and // any tag these settings cannot give a request. func (e *environment) checkTags(cfg *Config) { for _, tag := range cfg.TagDeny { - e.check("SWW_TAG_DENY", checkTag(cfg, tag)) + e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag)) } for tag := range cfg.TagLimitPercent { - e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag)) + e.check("SWW_WAF_TAG_LIMIT_PERCENT", checkTag(cfg, tag)) } } @@ -1216,13 +1228,13 @@ func requestCanGetTag(cfg *Config, tag string) bool { }) } -// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT +// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT // names an asn= or country= tag. func (c *Config) TagsNeedLookup() bool { return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) } -// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn= +// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn= // or country= tag. func tagLimitNeedsLookups(cfg *Config) bool { for tag := range cfg.TagLimitPercent { @@ -1392,7 +1404,7 @@ func (e *environment) logRemoteURL(name string) *url.URL { // unset. Its value names a file already, so, unlike the other settings, // it has no _FILE form. Whoever uses the file reads it. func (e *environment) filePath(name string) string { - path, _ := e.lookupEnv(name) + path, _ := readSetting(e.v, name) e.settings = append(e.settings, slog.String(name, path)) return path @@ -1402,7 +1414,7 @@ func (e *environment) filePath(name string) string { // certificates. Unset or empty, it is nil. Its value names a file // already, so, unlike the other settings, it has no _FILE form. func (e *environment) certificates(name string) *x509.CertPool { - path, _ := e.lookupEnv(name) + path, _ := readSetting(e.v, name) e.settings = append(e.settings, slog.String(name, path)) if path == "" { @@ -1435,14 +1447,14 @@ func (e *environment) facility(name, defaultValue string) int { return number } -// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It +// instanceName reads SWW_WAF_INSTANCE_NAME, by default the host's name. It // must be valid UTF-8: the metrics library panics on a label that is not. func (e *environment) instanceName() string { hostname, _ := os.Hostname() // "" when the host has no name to give - value := e.value("SWW_INSTANCE_NAME", hostname) + value := e.value("SWW_WAF_INSTANCE_NAME", hostname) if !utf8.ValidString(value) { - e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) + e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) } return value @@ -1467,7 +1479,7 @@ func (e *environment) appName( case set: e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) case sending: - e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", + e.check(name, fmt.Errorf("is unset, and SWW_WAF_INSTANCE_NAME %q, its default, %w", value, errNotAppName)) } @@ -1475,22 +1487,22 @@ func (e *environment) appName( } // checkInstanceNameForNtfy refuses an instance name that holds a control -// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent +// character while ntfySet, SWW_WAF_ALERT_NTFY_URL being set: ntfy is sent // the instance name in a header, which cannot hold one. func (e *environment) checkInstanceNameForNtfy( instanceName string, ntfySet bool, ) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { - e.check("SWW_INSTANCE_NAME", fmt.Errorf( - "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", + e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf( + "%q %w, and is sent to ntfy in a header while SWW_WAF_ALERT_NTFY_URL is set", instanceName, errControlCharacter)) } } // webhookURL reads a setting that is a URL each alert is posted to: -// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or -// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted +// SWW_WAF_ALERT_WEBHOOK_URL, SWW_WAF_ALERT_SLACK_WEBHOOK_URL or +// SWW_WAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted // there. The log shows ******** in place of its path and query, and an // error shows none of it, since a webhook or an ntfy topic can carry its // secret there. @@ -1942,7 +1954,7 @@ func parseCountry(value string) (string, error) { // ParseASN reads an AS number such as AS64496, in either case, and // returns it as the lookup gives it: AS and the number, in capitals and -// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is +// without leading zeros. The file SWW_WAF_ASN_LIMIT_PERCENT_URL names is // read with it too. func ParseASN(value string) (string, error) { digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS") @@ -1969,7 +1981,7 @@ const ( ExploitSignatureTag = "exploitsignature" ) -// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the +// TagList is an address list of SWW_WAF_TAG_LISTS: the tag it adds to the // requests of the clients it holds, and the URL it is fetched from. type TagList struct { Name string @@ -2162,7 +2174,7 @@ func parsePercents( } // ParsePercent reads a percentage, a whole number from 0 to 100. The file -// SWW_ASN_LIMIT_PERCENT_URL names is read with it too. +// SWW_WAF_ASN_LIMIT_PERCENT_URL names is read with it too. func ParsePercent(value string) (int64, error) { percent, err := strconv.ParseInt(value, 10, 64) if err != nil || percent < 0 || percent > 100 { @@ -2393,7 +2405,7 @@ const ( maxLabelLength = 63 // labelChars are the characters a label of a DNS zone may hold. labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" - // dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER + // dnsPort is the port a resolver is asked on when SWW_WAF_DNSBL_RESOLVER // gives none. dnsPort = 53 ) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 37e8b7b..13f0924 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,106 +17,109 @@ import ( "testing" "time" + "github.com/spf13/viper" + "sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" ) // The settings, by name. const ( - listenAddr = "SWW_LISTEN_ADDR" - mode = "SWW_MODE" - ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX" - maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS" - allowNets = "SWW_ALLOW_NETS" - rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" - denyNets = "SWW_DENY_NETS" - rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE" - rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR" - rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" - rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS" - bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE" - bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR" - bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY" - bytesCount = "SWW_BYTES_COUNT" - lookupSource = "SWW_LOOKUP_SOURCE" - lookupDBPath = "SWW_LOOKUP_DB_PATH" - lookupTimeout = "SWW_LOOKUP_TIMEOUT" - addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" - allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" - tagLists = "SWW_TAG_LISTS" - tagDeny = "SWW_TAG_DENY" - tagLimitPercent = "SWW_TAG_LIMIT_PERCENT" - asnBytesPercent = "SWW_ASN_BYTES_PERCENT" - countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" - unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" - asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL" - blocklistURLs = "SWW_BLOCKLIST_URLS" - blocklistRefresh = "SWW_BLOCKLIST_REFRESH" - blocklistAction = "SWW_BLOCKLIST_ACTION" - dnsblZones = "SWW_DNSBL_ZONES" - dnsblResolver = "SWW_DNSBL_RESOLVER" - abuseIPDBKey = "SWW_ABUSEIPDB_KEY" - abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE" - abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET" - reputationAction = "SWW_REPUTATION_ACTION" - reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL" - reputationTimeout = "SWW_REPUTATION_TIMEOUT" - routeWatchURL = "SWW_ROUTEWATCH_URL" - crowdSecURL = "SWW_CROWDSEC_LAPI_URL" - crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" - banResponse = "SWW_BAN_RESPONSE" - limitBanDuration = "SWW_LIMIT_BAN_DURATION" - limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW" - maxBanDuration = "SWW_MAX_BAN_DURATION" - attackBanDuration = "SWW_ATTACK_BAN_DURATION" - maxBans = "SWW_MAX_BANS" - banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX" - stateDir = "SWW_STATE_DIR" - stateWriteDelay = "SWW_STATE_WRITE_DELAY" - stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" - adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name - metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name - metricsTopN = "SWW_METRICS_TOP_N" - instanceName = "SWW_INSTANCE_NAME" - logRequestHeaders = "SWW_LOG_REQUEST_HEADERS" - logLevel = "SWW_LOG_LEVEL" - rulesDir = "SWW_RULES_DIR" - rulesEnabled = "SWW_RULES_ENABLED" - wafMode = "SWW_WAF_MODE" - wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL" - wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD" - wafDisabledRules = "SWW_WAF_DISABLED_RULES" - wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" - wafBodyLimit = "SWW_WAF_BODY_LIMIT" - exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE" - trapPaths = "SWW_TRAP_PATHS" - errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" - logRemoteURL = "SWW_LOG_REMOTE_URL" - logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE" - logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER" - logRemoteFacility = "SWW_LOG_REMOTE_FACILITY" - logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME" - alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" - alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS" - alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL" - alertNtfyURL = "SWW_ALERT_NTFY_URL" - alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name - alertEvents = "SWW_ALERT_EVENTS" - alertCooldown = "SWW_ALERT_COOLDOWN" - alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" - anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX" - anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" - watchNets = "SWW_WATCH_NETS" + listenAddr = "SWW_WAF_LISTEN_ADDR" + mode = "SWW_WAF_MODE" + ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX" + maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS" + allowNets = "SWW_WAF_ALLOW_NETS" + rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS" + denyNets = "SWW_WAF_DENY_NETS" + rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE" + rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR" + rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY" + rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS" + bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE" + bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR" + bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY" + bytesCount = "SWW_WAF_BYTES_COUNT" + lookupSource = "SWW_WAF_LOOKUP_SOURCE" + lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH" + lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT" + addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS" + allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES" + tagLists = "SWW_WAF_TAG_LISTS" + tagDeny = "SWW_WAF_TAG_DENY" + tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT" + asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT" + countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT" + unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT" + asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL" + blocklistURLs = "SWW_WAF_BLOCKLIST_URLS" + blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH" + blocklistAction = "SWW_WAF_BLOCKLIST_ACTION" + dnsblZones = "SWW_WAF_DNSBL_ZONES" + dnsblResolver = "SWW_WAF_DNSBL_RESOLVER" + abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY" + abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE" + abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET" + reputationAction = "SWW_WAF_REPUTATION_ACTION" + reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL" + reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT" + routeWatchURL = "SWW_WAF_ROUTEWATCH_URL" + crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL" + crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY" + banResponse = "SWW_WAF_BAN_RESPONSE" + limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION" + limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW" + maxBanDuration = "SWW_WAF_MAX_BAN_DURATION" + attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION" + maxBans = "SWW_WAF_MAX_BANS" + banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX" + stateDir = "SWW_WAF_STATE_DIR" + stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY" + stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL" + adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name + metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name + metricsTopN = "SWW_WAF_METRICS_TOP_N" + instanceName = "SWW_WAF_INSTANCE_NAME" + logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS" + logLevel = "SWW_WAF_LOG_LEVEL" + rulesDir = "SWW_WAF_RULES_DIR" + rulesEnabled = "SWW_WAF_RULES_ENABLED" + wafMode = "SWW_WAF_CORE_RULE_SET_MODE" + wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL" + wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD" + wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES" + wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS" + wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT" + exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE" + trapPaths = "SWW_WAF_TRAP_PATHS" + errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD" + logRemoteURL = "SWW_WAF_LOG_REMOTE_URL" + logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE" + logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER" + logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY" + logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME" + alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL" + alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS" + alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL" + alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL" + alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name + alertEvents = "SWW_WAF_ALERT_EVENTS" + alertCooldown = "SWW_WAF_ALERT_COOLDOWN" + alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR" + anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX" + anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX" + watchNets = "SWW_WAF_WATCH_NETS" ) // The anomaly thresholds: each of the prefixes below, which name a scope, // followed by each of the four ends. const ( - anomalyClient = "SWW_ANOMALY_CLIENT_" - anomalyNet = "SWW_ANOMALY_NET_" - anomalyASN = "SWW_ANOMALY_ASN_" - anomalyTotal = "SWW_ANOMALY_TOTAL_" - watch = "SWW_WATCH_" + anomalyClient = "SWW_WAF_ANOMALY_CLIENT_" + anomalyNet = "SWW_WAF_ANOMALY_NET_" + anomalyASN = "SWW_WAF_ANOMALY_ASN_" + anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_" + watch = "SWW_WAF_WATCH_" requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerHour = "REQUESTS_PER_HOUR" @@ -157,21 +160,21 @@ func loggedAnomalyDefaults() map[string]string { return logged } -// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and -// defaultAlertCooldown that of SWW_ALERT_COOLDOWN. +// defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and +// defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN. const ( defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," + "source_failure,file_error" defaultAlertCooldown = "15m" ) -// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL. +// defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL. const defaultReputationCacheTTL = "24h" -// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES. +// defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES. const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" -// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS. +// defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS. const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + "content-type,origin,range" @@ -196,8 +199,8 @@ const ( otherToken = "fedcba9876543210fedcba9876543210" ) -// instance is an SWW_INSTANCE_NAME that is a valid app name too, and -// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines. +// instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and +// remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines. const ( instance = "fsn1app1/blog" remoteURL = "syslog+udp://192.0.2.1:514" @@ -212,7 +215,7 @@ const enabled = "true" // disabled is false, as a setting's value. const disabled = "false" -// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and +// defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and // fileSource the source that is the lookup database. const ( defaultLookupSource = "geojs" @@ -222,18 +225,16 @@ const ( // environment is a set of environment variables, for FromEnvironment. type environment map[string]string -// lookupEnv reads one of the variables, as os.LookupEnv does. -func (e environment) lookupEnv(name string) (string, bool) { - value, ok := e[name] - - return value, ok +// viper returns a viper holding each of the variables. +func (e environment) viper() *viper.Viper { + return configtest.Viper(e) } // fromEnvironment reads the settings from env, which must be valid. func fromEnvironment(t *testing.T, env environment) *config.Config { t.Helper() - cfg, err := config.FromEnvironment(env.lookupEnv) + cfg, err := config.FromEnvironment(env.viper()) if err != nil { t.Fatalf("settings %v: %v", env, err) } @@ -462,7 +463,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) { t.Parallel() _, err := config.FromEnvironment( - environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv) + environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper()) want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` + `starting with /, such as /assets/` @@ -517,7 +518,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -646,7 +647,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -771,7 +772,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) { {logRemoteAppName, ""}, {logRemoteAppName, "my app"}, {logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)}, } { - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err) } @@ -788,7 +789,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) { t.Fatalf("write %s: %v", caFile, err) } - _, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv) + _, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper()) want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate` if err == nil || err.Error() != want { @@ -803,7 +804,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) { sending := environment{logRemoteURL: remoteURL, instanceName: spaced} - _, err := config.FromEnvironment(sending.lookupEnv) + _, err := config.FromEnvironment(sending.viper()) want := logRemoteAppName + `: is unset, and ` + instanceName + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + @@ -833,7 +834,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) { logRemoteURL: remoteURL, instanceName: instance, logRemoteAppName: "my app", - }.lookupEnv) + }.viper()) want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + `characters without a space, such as blog` @@ -964,7 +965,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) { // An item that is not a header is named by its place, not shown. _, err := config.FromEnvironment(environment{ alertWebhookHeaders: "X-Team:ops," + secret, - }.lookupEnv) + }.viper()) want := alertWebhookHeaders + ": item 2 is not a header name followed by : " + "and the header's value, such as Authorization:Bearer " @@ -1001,7 +1002,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) { "ftp://hooks.example/services/" + secret, "https://hooks.example/services/%zz" + secret, } { - _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) + _, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper()) want := alertWebhookURL + ": is not an http or https URL without a user or " + "a fragment, such as https://alerts.example/sww" @@ -1052,7 +1053,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing. "set": {alertNtfyToken: token + "\r"}, "in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")}, } { - _, err := config.FromEnvironment(env.lookupEnv) + _, err := config.FromEnvironment(env.viper()) want := alertNtfyToken + ": holds a control character, such as the " + "carriage return of a Windows line end" @@ -1069,7 +1070,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin _, err := config.FromEnvironment(environment{ instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts", - }.lookupEnv) + }.viper()) want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + `carriage return of a Windows line end, and is sent to ntfy in a header ` + @@ -1094,7 +1095,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) { "set": {instanceName: latin1}, "in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")}, } { - _, err := config.FromEnvironment(env.lookupEnv) + _, err := config.FromEnvironment(env.viper()) want := instanceName + `: "caf\xe9" is not valid UTF-8` if err == nil || err.Error() != want { @@ -1109,9 +1110,10 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) { _, err := config.FromEnvironment(environment{ tagDeny: "country=cn,country=ru", allowedCountries: "de,RU", - }.lookupEnv) + }.viper()) if err == nil { - t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted") + t.Fatal("ru in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and " + + "SWW_WAF_TAG_DENY was accepted") } if !strings.HasPrefix(err.Error(), allowedCountries+": ") || @@ -1171,7 +1173,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) { lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it", }, } { - _, err := config.FromEnvironment(tc.env.lookupEnv) + _, err := config.FromEnvironment(tc.env.viper()) if err == nil || err.Error() != tc.want { t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want) } @@ -1200,7 +1202,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) { _, err := config.FromEnvironment(environment{ lookupSource: off, name: value, - }.lookupEnv) + }.viper()) if err == nil || !strings.HasPrefix(err.Error(), name+": ") || !strings.Contains(err.Error(), lookupSource+" is off") { t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource) @@ -1209,7 +1211,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) { } // Set empty, the lists need nothing looked up, and nor does - // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the + // SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the // lookup does not give, an anomaly threshold per AS number that is off, // any other anomaly threshold, or a blocklist. env := environment{ @@ -1351,7 +1353,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1445,14 +1447,14 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) { {environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny}, {environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny}, } { - _, err := config.FromEnvironment(stopped.env.lookupEnv) + _, err := config.FromEnvironment(stopped.env.viper()) if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") { t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name) } } badURL := environment{routeWatchURL: "ftp://routewatch.example"} - _, err := config.FromEnvironment(badURL.lookupEnv) + _, err := config.FromEnvironment(badURL.viper()) want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` + "without a user or a fragment, such as https://routewatch.example" @@ -1507,7 +1509,7 @@ func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(tc.env.lookupEnv) + _, err := config.FromEnvironment(tc.env.viper()) switch { case tc.want == "" && err != nil: @@ -1545,7 +1547,7 @@ func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) { {tagLimitPercent, tag + ":50," + tag + " :25", twice}, {tagLimitPercent, tag + ":50,,", emptyItem}, } { - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1567,7 +1569,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) { {"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}}, { "the CrowdSec decision list", lapi + "/v1/decisions", - environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"}, + environment{"SWW_WAF_CROWDSEC_LAPI_URL": lapi, "SWW_WAF_CROWDSEC_LAPI_KEY": "key"}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -1575,10 +1577,10 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) { tc.env[tagLists] = "dup=" + tc.listURL - want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` + - "SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too" + want := tagLists + `: "` + tc.listURL + `" is in SWW_WAF_BLOCKLIST_URLS, is ` + + "SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too" - _, err := config.FromEnvironment(tc.env.lookupEnv) + _, err := config.FromEnvironment(tc.env.viper()) if err == nil || err.Error() != want { t.Errorf("error %v, want %s", err, want) } @@ -1647,7 +1649,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1665,7 +1667,7 @@ const ( asnURL = "https://lists.example/asn.txt" ) -// The actions of SWW_BLOCKLIST_ACTION, as Config gives them. +// The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them. const ( actionDeny = "deny" actionLimit = "limit" @@ -1744,7 +1746,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1759,9 +1761,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) { _, err := config.FromEnvironment(environment{ blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, - }.lookupEnv) + }.viper()) - want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too` + want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too` if err == nil || err.Error() != want { t.Errorf("error %v, want %s", err, want) } @@ -1886,7 +1888,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -1954,7 +1956,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) { {spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`}, {spamhaus + ",,", "has an empty item in its list"}, } { - _, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper()) want := dnsblZones + ": " + tc.want if err == nil || err.Error() != want { @@ -2007,7 +2009,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) want := tc.name + ": " + tc.want if err == nil || err.Error() != want { @@ -2064,7 +2066,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { notLAPIURL = " is not an http or https URL without a user or a fragment, " + "such as http://172.17.0.1:8080" anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` + - `SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too` + `SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too` ) for _, tc := range []struct { @@ -2121,7 +2123,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(tc.env.lookupEnv) + _, err := config.FromEnvironment(tc.env.viper()) if err == nil || err.Error() != tc.want { t.Errorf("error %v, want %s", err, tc.want) } @@ -2274,14 +2276,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) { // wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule // files off, and either setting turning off the Core Rule Set, the tag -// exploitsignature in SWW_TAG_DENY stops the start. +// exploitsignature in SWW_WAF_TAG_DENY stops the start. func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) { t.Helper() for _, name := range []string{wafMode, wafAnomalyThreshold} { _, err := config.FromEnvironment(environment{ tagDeny: exploitTag, rulesEnabled: disabled, name: off, - }.lookupEnv) + }.viper()) if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") { t.Errorf("%s=off with rule files off gave the error %v, want one naming %s", name, err, tagDeny) @@ -2314,7 +2316,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) + _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper()) if err == nil { t.Fatalf("%s=%q was accepted", tc.name, tc.value) } @@ -2339,7 +2341,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) { t.Run(value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv) + _, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper()) if err == nil || err.Error() != logRequestHeaders+": "+want { t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want) } @@ -2356,7 +2358,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) { t.Run(name+"="+value, func(t *testing.T) { t.Parallel() - _, err := config.FromEnvironment(environment{name: value}.lookupEnv) + _, err := config.FromEnvironment(environment{name: value}.viper()) want := name + ": is shorter than 32 characters" if err == nil || err.Error() != want { @@ -2412,7 +2414,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) { _, err := config.FromEnvironment(environment{ bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"), - }.lookupEnv) + }.viper()) want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off` if err == nil || err.Error() != want { @@ -2423,7 +2425,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) { logRemoteURL: remoteURL, instanceName: instance, logRemoteAppName + "_FILE": writeFile(t, "my app\n"), - }.lookupEnv) + }.viper()) want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + `characters without a space, such as blog` @@ -2438,7 +2440,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) { _, err := config.FromEnvironment(environment{ metricsToken: token, metricsToken + "_FILE": writeFile(t, token), - }.lookupEnv) + }.viper()) want := metricsToken + ": is set, and so is " + metricsToken + "_FILE; set only one of them" @@ -2453,7 +2455,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) { dir := t.TempDir() for _, path := range []string{filepath.Join(dir, "missing"), dir} { - _, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv) + _, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper()) want := metricsToken + "_FILE: cannot be read: " if err == nil || !strings.HasPrefix(err.Error(), want) { diff --git a/internal/config/configtest/configtest.go b/internal/config/configtest/configtest.go new file mode 100644 index 0000000..7aa953b --- /dev/null +++ b/internal/config/configtest/configtest.go @@ -0,0 +1,16 @@ +// Package configtest gives the tests of the packages that read settings the +// settings they choose. +package configtest + +import "github.com/spf13/viper" + +// Viper returns a viper holding each of settings, as the viper config.NewViper +// returns holds a process's environment. +func Viper(settings map[string]string) *viper.Viper { + v := viper.New() + for name, value := range settings { + v.Set(name, value) + } + + return v +} diff --git a/internal/config/rproxy.go b/internal/config/rproxy.go index 3f2988c..c0e9b50 100644 --- a/internal/config/rproxy.go +++ b/internal/config/rproxy.go @@ -8,38 +8,39 @@ import ( "net/netip" "net/url" "strconv" - "strings" "time" + + "github.com/spf13/viper" ) // RProxy is the settings of `sww rproxy`. A size or time limit of zero // is off. type RProxy struct { - // ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). + // ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR). ListenAddr string - // UpstreamURL is the app (SWRP_UPSTREAM_URL). + // UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL). UpstreamURL *url.URL // TrustedProxies are the netblocks whose X-Forwarded-For is believed - // (SWRP_TRUSTED_PROXIES). + // (SWW_RPROXY_TRUSTED_PROXIES). TrustedProxies []netip.Prefix - // WAFBackend is discriminator's URL (SWRP_WAF_BACKEND). + // WAFBackend is discriminator's URL (SWW_RPROXY_WAF_BACKEND). WAFBackend *url.URL // Timeout is how long rproxy waits for discriminator's answer to a question, - // and to a report (SWRP_TIMEOUT_MS). It cannot be off. + // and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off. Timeout time.Duration // DefaultDisposition is what is done with a request when discriminator cannot be // connected to, does not answer at all, or does not answer within Timeout: - // allow or deny (SWRP_DEFAULT_DISPOSITION). + // allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION). DefaultDisposition string - // MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and - // MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE). + // MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and + // MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE). MaxRequestSize int64 MaxResponseSize int64 // MaxRequestTime bounds sending the whole request: the visitor's request // line and headers, and then, from their end, its body on its way to the - // app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from + // app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from // the end of the request to its last byte reaching the visitor - // (SWRP_MAX_RESPONSE_TIME_MS). + // (SWW_RPROXY_MAX_RESPONSE_TIME_MS). MaxRequestTime time.Duration MaxResponseTime time.Duration @@ -58,24 +59,26 @@ var ( "is not a whole number of milliseconds above zero, such as 60000, or off") ) -// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally -// os.LookupEnv. A setting that is not set takes its default, and -// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but -// invalid is an error that names it. Since rproxy never touches disk, its -// settings have no _FILE form. -func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { - env := &environment{lookupEnv: withoutFileForms(lookupEnv)} +// RProxyFromEnvironment reads rproxy's settings through v, normally the viper +// NewViper returns, bound to the process's environment. A setting that is not +// set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be +// set. A setting may instead be given as a file: the variable named by the +// setting's name with _FILE added names the file, which is read now. A setting +// that is set but invalid is an error that names it. +func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) { + env := &environment{v: v} cfg := &RProxy{ - ListenAddr: env.address("SWRP_LISTEN_ADDR"), - UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), - TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges), - WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), - Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"), - DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"), - MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"), - MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"), - MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"), - MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"), + ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"), + UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL), + TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges), + WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"), + Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"), + DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"), + MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"), + MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"), + MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"), + MaxResponseTime: env.millisecondsOrOff( + "SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"), } if env.err != nil { @@ -87,15 +90,15 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro return cfg, nil } -// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and -// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other +// RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and +// SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other // setting, so it reads no other. func RProxyListenAddrAndUpstreamURL( - lookupEnv func(string) (string, bool), + v *viper.Viper, ) (string, *url.URL, error) { - env := &environment{lookupEnv: withoutFileForms(lookupEnv)} - listenAddr := env.address("SWRP_LISTEN_ADDR") - upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL) + env := &environment{v: v} + listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR") + upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL) if env.err != nil { return "", nil, env.err @@ -104,20 +107,6 @@ func RProxyListenAddrAndUpstreamURL( return listenAddr, upstreamURL, nil } -// withoutFileForms returns lookupEnv finding no setting named with _FILE, since -// rproxy never touches disk. -func withoutFileForms( - lookupEnv func(string) (string, bool), -) func(string) (string, bool) { - return func(name string) (string, bool) { - if strings.HasSuffix(name, "_FILE") { - return "", false - } - - return lookupEnv(name) - } -} - // LogValue makes an RProxy log as each setting's name with the value it was // given, or its default. func (c *RProxy) LogValue() slog.Value { diff --git a/internal/config/rproxy_test.go b/internal/config/rproxy_test.go index ae72bf5..07d12ba 100644 --- a/internal/config/rproxy_test.go +++ b/internal/config/rproxy_test.go @@ -10,20 +10,21 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" ) // rproxy's settings, by name, and their values in these tests. const ( - wafBackend = "SWRP_WAF_BACKEND" - rproxyTimeout = "SWRP_TIMEOUT_MS" - rproxyDefault = "SWRP_DEFAULT_DISPOSITION" - rproxyListen = "SWRP_LISTEN_ADDR" - rproxyUpstream = "SWRP_UPSTREAM_URL" - rproxyTrusted = "SWRP_TRUSTED_PROXIES" - rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE" - rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" - rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" - rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" + wafBackend = "SWW_RPROXY_WAF_BACKEND" + rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS" + rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION" + rproxyListen = "SWW_RPROXY_LISTEN_ADDR" + rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL" + rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES" + rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE" + rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE" + rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS" + rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS" backendURL = "http://discriminator:8080" rproxyDefaultAddr = ":8080" rproxyDefaultApp = "http://127.0.0.1:8081" @@ -32,11 +33,7 @@ const ( // rproxyConfig reads rproxy's settings from env. func rproxyConfig(env map[string]string) (*config.RProxy, error) { - return config.RProxyFromEnvironment(func(name string) (string, bool) { - value, ok := env[name] - - return value, ok - }) + return config.RProxyFromEnvironment(configtest.Viper(env)) } func TestRProxyDefaults(t *testing.T) { @@ -177,31 +174,41 @@ func TestRProxyNeedsItsBackend(t *testing.T) { } } -func TestRProxyReadsNoFile(t *testing.T) { +func TestRProxySettingReadFromFile(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "backend") - err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600) + err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600) if err != nil { t.Fatalf("write: %v", err) } - _, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path}) - if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") { - t.Errorf("error %v, want %s still required", err, wafBackend) - } - - cfg, err := rproxyConfig(map[string]string{ - wafBackend: backendURL, - rproxyTimeout + "_FILE": "/nonexistent/timeout", - }) + cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path}) if err != nil { - t.Fatalf("settings: %v, with the file left unread", err) + t.Fatalf("settings: %v", err) } - if cfg.Timeout != 2*time.Second { - t.Errorf("timeout %v, want the default", cfg.Timeout) + if cfg.WAFBackend.String() != backendURL { + t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL) + } +} + +func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + for _, path := range []string{filepath.Join(dir, "missing"), dir} { + _, err := rproxyConfig(map[string]string{ + wafBackend: backendURL, + rproxyTimeout + "_FILE": path, + }) + + want := rproxyTimeout + "_FILE: cannot be read: " + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("%s: error %v, want one starting %s", path, err, want) + } } } diff --git a/internal/config/viper.go b/internal/config/viper.go new file mode 100644 index 0000000..90ea26a --- /dev/null +++ b/internal/config/viper.go @@ -0,0 +1,16 @@ +package config + +import "github.com/spf13/viper" + +// NewViper returns the viper that reads the settings: each setting's +// environment variable, bound to its own name. Every setting of discriminator +// and rproxy is read through it, and none is read with os.Getenv or +// os.LookupEnv outside it. An environment variable that is set but empty is +// set, as one that holds a value is, which viper does not take for granted. +func NewViper() *viper.Viper { + v := viper.New() + v.AutomaticEnv() + v.AllowEmptyEnv(true) + + return v +} diff --git a/internal/config/viper_test.go b/internal/config/viper_test.go new file mode 100644 index 0000000..e7ce54d --- /dev/null +++ b/internal/config/viper_test.go @@ -0,0 +1,34 @@ +package config_test + +import ( + "testing" + + "sneak.berlin/go/smallwebwaf/internal/config" +) + +// These tests set real environment variables, so none runs in parallel. + +func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) { + t.Setenv(adminToken, "") + + _, err := config.FromEnvironment(config.NewViper()) + + want := adminToken + ": is shorter than 32 characters" + if err == nil || err.Error() != want { + t.Fatalf("error %v, want %s", err, want) + } +} + +func TestListSetEmptyInTheEnvironmentReplacesItsDefault(t *testing.T) { + t.Setenv(wafBackend, backendURL) + t.Setenv(rproxyTrusted, "") + + cfg, err := config.RProxyFromEnvironment(config.NewViper()) + if err != nil { + t.Fatalf("settings: %v", err) + } + + if len(cfg.TrustedProxies) != 0 { + t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies) + } +} diff --git a/internal/coreruleset/coreruleset.go b/internal/coreruleset/coreruleset.go index f537401..091c437 100644 --- a/internal/coreruleset/coreruleset.go +++ b/internal/coreruleset/coreruleset.go @@ -1,7 +1,7 @@ // Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // method, the URL with its query and the headers of a request, and on its -// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww -// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as +// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the eight changes sww +// makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as // "Attack detection" under "Settings" in SPEC.md describes them. It reads no // response. // @@ -32,16 +32,16 @@ import ( // directives are the Core Rule Set as sww runs it, with the // paranoia level for %d, bodyDirectives for the first %s while -// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and +// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second, and // giteaExclusions for the third. // Each rule sww adds has an id from 900000 to 900999, the ids the // Core Rule Set keeps for the rules that set it up, which -// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no +// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no // setting switches one off. Coraza joins a line ending in \ to the next, // without the spaces at the start of the next. const directives = ` # The engine only detects. sww compares the request's anomaly -# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode +# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode # alike. It reads no body, unless bodyDirectives switch that on. SecRuleEngine DetectionOnly SecRequestBodyAccess Off @@ -183,16 +183,16 @@ var cookiesNotRead = []string{"gitea_flash", "redirect_to"} // Params are what New needs. type Params struct { - // ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4. + // ParanoiaLevel is SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4. ParanoiaLevel int // DisabledRules are the ids of the rules switched off - // (SWW_WAF_DISABLED_RULES). + // (SWW_WAF_CORE_RULE_SET_DISABLED_RULES). DisabledRules []int // BodyLimit is the most of a request body the Core Rule Set reads - // (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. + // (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none. BodyLimit int64 // ExclusionsFile is the path of a file of exclusions loaded before the - // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while + // Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // there is none. ExclusionsFile string } @@ -218,7 +218,7 @@ var errSwwRuleID = errors.New("an id of sww's own rules") // SecRuleRemoveById. The Core Rule Set's rules load after the file, so // the directive finds none of them. var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " + - "switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + + "switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " + "with a ctl exclusion") // directivesIn returns the directives in text as Coraza reads them: it @@ -404,7 +404,7 @@ func (c *CoreRuleSet) Inspect( } // readBody reads body, the body of the request in tx, which has run on -// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is +// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is // of a kind the Core Rule Set reads: form data and multipart, of which it // reads the first c.bodyLimit bytes, and JSON and XML, which it reads only // when they are no longer than that, since they cannot be read in part. diff --git a/internal/coreruleset/coreruleset_test.go b/internal/coreruleset/coreruleset_test.go index 0dacf68..f21d966 100644 --- a/internal/coreruleset/coreruleset_test.go +++ b/internal/coreruleset/coreruleset_test.go @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) -// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off +// defaultDisabledRules are the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off // by default. // //nolint:gochecknoglobals // a constant cannot be a list @@ -530,7 +530,7 @@ func giteaRequests(t *testing.T) []request { func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { t.Parallel() - const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default crs := atDefaults(t) @@ -555,7 +555,7 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { } } -// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies. +// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies. const bodyLimit = 8 << 10 // The Content-Type headers of the kinds of body the Core Rule Set reads. diff --git a/internal/discriminator/abuseipdb.go b/internal/discriminator/abuseipdb.go index 0edcc58..1d9efda 100644 --- a/internal/discriminator/abuseipdb.go +++ b/internal/discriminator/abuseipdb.go @@ -1,7 +1,7 @@ package discriminator -// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY, -// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION. +// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY, +// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION. import ( "context" @@ -16,12 +16,12 @@ import ( // abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with // the score, when its score of the client is a hit, and refuses the request -// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While -// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is +// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While +// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is // checked in the background, by the request's address, if its history counts // an offence, and the request does not wait for the answer. The score is then // used for each address of the client. Its figures are the score and -// SWW_ABUSEIPDB_MIN_SCORE. +// SWW_WAF_ABUSEIPDB_MIN_SCORE. type abuseIPDB struct{} func (abuseIPDB) name() string { return "abuseipdb" } @@ -43,7 +43,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict { rq.abuseIPDBHit = true rq.addTag(config.AbuseIPDBTag) rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, - "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE") + "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE") if rq.h.config.ReputationAction != deny { return nil diff --git a/internal/discriminator/admin.go b/internal/discriminator/admin.go index ba686bf..863edfc 100644 --- a/internal/discriminator/admin.go +++ b/internal/discriminator/admin.go @@ -51,7 +51,7 @@ func (rq *request) answerHealth() { // answerAdmin answers a request for one of discriminator's own endpoints. Each // needs a token, sent as Authorization: Bearer : the metrics -// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is +// SWW_WAF_METRICS_TOKEN, the others SWW_WAF_ADMIN_TOKEN. A request without it is // refused with 401. An endpoint whose token is unset answers 404, as any other // path does. func (rq *request) answerAdmin() { diff --git a/internal/discriminator/admin_test.go b/internal/discriminator/admin_test.go index e679954..3480050 100644 --- a/internal/discriminator/admin_test.go +++ b/internal/discriminator/admin_test.go @@ -17,8 +17,8 @@ import ( ) const ( - adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name - // adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer + adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name + // adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set, and adminBearer // how a request carries it. adminSecret = "fedcba9876543210fedcba9876543210" adminBearer = "Bearer " + adminSecret @@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) { } } -// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens. +// adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens. type adminEndpoint struct { method, path, body string } -// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN +// adminEndpoints returns a request to each endpoint SWW_WAF_ADMIN_TOKEN // opens: listing the bans, banning otherClient for an hour, lifting the // bans on otherClient, and asking about otherClient. func adminEndpoints() []adminEndpoint { diff --git a/internal/discriminator/alerts_test.go b/internal/discriminator/alerts_test.go index 4370aad..8e6bf1d 100644 --- a/internal/discriminator/alerts_test.go +++ b/internal/discriminator/alerts_test.go @@ -15,8 +15,8 @@ import ( ) const ( - alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" - alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" + alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL" + alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR" // alertInstance is the instance every alert of these tests gives. alertInstance = "fsn1app1/blog" ) diff --git a/internal/discriminator/anomaly_test.go b/internal/discriminator/anomaly_test.go index 504a883..fdcbffa 100644 --- a/internal/discriminator/anomaly_test.go +++ b/internal/discriminator/anomaly_test.go @@ -21,11 +21,11 @@ import ( // The anomaly thresholds: the prefix of a scope followed by the end of a // count. const ( - anomalyClient = "SWW_ANOMALY_CLIENT_" - anomalyNet = "SWW_ANOMALY_NET_" - anomalyASN = "SWW_ANOMALY_ASN_" - anomalyTotal = "SWW_ANOMALY_TOTAL_" - anomalyWatch = "SWW_WATCH_" + anomalyClient = "SWW_WAF_ANOMALY_CLIENT_" + anomalyNet = "SWW_WAF_ANOMALY_NET_" + anomalyASN = "SWW_WAF_ANOMALY_ASN_" + anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_" + anomalyWatch = "SWW_WAF_WATCH_" requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerHour = "REQUESTS_PER_HOUR" @@ -35,9 +35,9 @@ const ( // The other anomaly settings. const ( - anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX" - anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" - watchNets = "SWW_WATCH_NETS" + anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX" + anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX" + watchNets = "SWW_WAF_WATCH_NETS" ) const ( @@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.7" // in SWW_ALLOW_NETS - exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS - denied = "192.0.2.20" // in SWW_DENY_NETS + allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS + exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS + denied = "192.0.2.20" // in SWW_WAF_DENY_NETS ) s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ diff --git a/internal/discriminator/answertime_test.go b/internal/discriminator/answertime_test.go index 684a8d6..0d966dc 100644 --- a/internal/discriminator/answertime_test.go +++ b/internal/discriminator/answertime_test.go @@ -23,10 +23,10 @@ import ( ) const ( - // answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time + // answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time // discriminator has to answer. answerTimeLimit = 2 * time.Second - // defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT. + // defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT. defaultLookupWait = 800 * time.Millisecond // The header limits SPEC.md gives. cookieLimit = 50 @@ -178,7 +178,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) { for _, env := range []map[string]string{ {wafMode: off}, {wafMode: "detect"}, - {wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"}, + {wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"}, } { t.Run(fmt.Sprint(env), func(t *testing.T) { t.Parallel() @@ -413,7 +413,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { action string }{ { - name: "SWW_DENY_NETS", env: map[string]string{denyNets: client}, + name: "SWW_WAF_DENY_NETS", env: map[string]string{denyNets: client}, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied, }, { diff --git a/internal/discriminator/bans.go b/internal/discriminator/bans.go index f1227ab..c6db65c 100644 --- a/internal/discriminator/bans.go +++ b/internal/discriminator/bans.go @@ -1,7 +1,7 @@ package discriminator // The heuristic active_ban, and the code that makes bans, which several -// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's. +// heuristics share. active_ban reads SWW_WAF_MODE; the bans are the ban ledger's. import ( "context" @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged +// banResponse is a refusal answered with SWW_WAF_BAN_RESPONSE, and logged // with action. func (rq *request) banResponse(action string) *refusal { return &refusal{status: rq.h.config.BanResponse, action: action} @@ -77,7 +77,7 @@ func limitFigures(hit ratelimit.Hit) map[string]any { // countedBytes returns the request's bytes, once it has ended, as the // byte limits and the anomaly thresholds count them: the response's body -// bytes, the request's, or both, as SWW_BYTES_COUNT says. +// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says. func (rq *request) countedBytes() int64 { response, request := rq.out.bytes, rq.requestBytes @@ -94,7 +94,7 @@ func (rq *request) countedBytes() int64 { // banForLimit bans the client's netblock at now for a broken limit, the // one hit names, notes the offence for the log line and counts the hit in // the metrics. status is what the client was sent, or is sent: -// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for +// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for // one whose bytes broke a byte limit, the refusal for one that broke the // error burst. The ban's notes give the client's limit percentage for a // rate limit or a byte limit; the error burst is not lowered. The ban sets @@ -237,7 +237,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s // wouldAlertBan reports whether the alert for a ban on netblock for cause // made at now would be sent. In observe mode the ban the request would // have made is worked out only then, at most once per -// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the +// SWW_WAF_ALERT_COOLDOWN and never with no webhook set: its notes count the // netblock's requests, which can mean going through every client. func (rq *request) wouldAlertBan( netblock netip.Prefix, now time.Time, cause string, @@ -303,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 { } // netblock is the netblock a ban on client covers: its IPv4 address, -// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup +// widened to SWW_WAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup // counts it in. func (h *handler) netblock(client netip.Addr) netip.Prefix { addr := client.Unmap() diff --git a/internal/discriminator/bans_test.go b/internal/discriminator/bans_test.go index cd3d2bb..589c390 100644 --- a/internal/discriminator/bans_test.go +++ b/internal/discriminator/bans_test.go @@ -26,7 +26,7 @@ const ( userAgent = "ban-test/1.0" // permanent is the log line's ban_expires for a permanent ban. permanent = "permanent" - // banResponseClose is the SWW_BAN_RESPONSE that closes the connection. + // banResponseClose is the SWW_WAF_BAN_RESPONSE that closes the connection. banResponseClose = "close" ) @@ -91,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) { } } - // A first ban lasts SWW_LIMIT_BAN_DURATION; one within - // SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. + // A first ban lasts SWW_WAF_LIMIT_BAN_DURATION; one within + // SWW_WAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. wantExpires(breakLimit(), 10*time.Minute) clk.advance(10*time.Minute + time.Hour) wantExpires(breakLimit(), 30*time.Minute) - // Later than that, SWW_LIMIT_BAN_DURATION again. + // Later than that, SWW_WAF_LIMIT_BAN_DURATION again. clk.advance(30*time.Minute + time.Hour + time.Second) wantExpires(breakLimit(), 10*time.Minute) clk.advance(10 * time.Minute) wantExpires(breakLimit(), 30*time.Minute) - // 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is + // 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is // permanent. clk.advance(30 * time.Minute) @@ -150,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { // In the IPv4 cases, client breaks the limit; these two are next to it. const ( - allowed = "203.0.113.60" // in SWW_ALLOW_NETS - exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS + allowed = "203.0.113.60" // in SWW_WAF_ALLOW_NETS + exempt = "203.0.113.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS ) for _, tc := range []struct { @@ -166,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { nil, []string{otherClient, exempt}, }, { - "the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets", + "the IPv4 netblock SWW_WAF_BAN_SCOPE_V4_PREFIX sets", map[string]string{banScopeV4Prefix: "24"}, client, []string{otherClient, exempt}, []string{"203.0.112.9", allowed}, }, @@ -175,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { []string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"}, }, { - "the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets", + "the IPv6 netblock SWW_WAF_IPV6_GROUP_PREFIX sets", map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1", []string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"}, }, @@ -249,10 +249,10 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWW_DENY_NETS + const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS for _, tc := range []struct { - setting string // "" leaves SWW_BAN_RESPONSE at its default + setting string // "" leaves SWW_WAF_BAN_RESPONSE at its default status int // 0 is the connection closed without an answer }{ {"", http.StatusForbidden}, diff --git a/internal/discriminator/biased.go b/internal/discriminator/biased.go index ef97adc..1c8c218 100644 --- a/internal/discriminator/biased.go +++ b/internal/discriminator/biased.go @@ -17,9 +17,9 @@ type percentage struct { } // biasedThresholdsSet reports whether a biased threshold can lower a -// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or -// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is -// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is +// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or +// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is +// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is // then needed before its request goes on. func biasedThresholdsSet(cfg *config.Config) bool { return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 || @@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool { // as looked up, each "" when unknown, and the blocklists, DNSBL zones and // AbuseIPDB that list it. Each is the lowest of those the settings give // it, the first of them in the order below when several are lowest: the -// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file -// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one -// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its +// percentage SWW_WAF_TAG_LIMIT_PERCENT gives its asn= tag, the one the file +// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one +// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its // other tags, for a client without a country, unless its request skipped -// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist -// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and +// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist +// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and // for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is -// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For -// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT +// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For +// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT // take the place of the first three for an AS number or a country they // list. func (rq *request) limitPercentages() (percentage, percentage) { @@ -48,25 +48,25 @@ func (rq *request) limitPercentages() (percentage, percentage) { unknown := percentage{percent: whole} if country == "" && !rq.skippedLookupWait { - unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"} + unknown = percentage{cfg.UnknownLimitPercent, "SWW_WAF_UNKNOWN_LIMIT_PERCENT"} } fetched := percentage{percent: whole} if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed { - fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"} + fetched = percentage{percent, "SWW_WAF_ASN_LIMIT_PERCENT_URL"} } blocklisted := percentage{percent: whole} if rq.blocklisted && cfg.BlocklistAction == "limit" { - blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"} + blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_WAF_BLOCKLIST_ACTION"} } reputationListed := percentage{percent: whole} if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" { - reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} + reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_WAF_REPUTATION_ACTION"} } - const tagSetting = "SWW_TAG_LIMIT_PERCENT" + const tagSetting = "SWW_WAF_TAG_LIMIT_PERCENT" asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched) countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting) @@ -81,11 +81,12 @@ func (rq *request) limitPercentages() (percentage, percentage) { asnBytes, countryBytes := asnRequests, countryRequests if _, listed := cfg.ASNBytesPercent[asn]; listed { - asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT") + asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_WAF_ASN_BYTES_PERCENT") } if _, listed := cfg.CountryBytesPercent[country]; listed { - countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT") + countryBytes = given(cfg.CountryBytesPercent, country, + "SWW_WAF_COUNTRY_BYTES_PERCENT") } return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted, diff --git a/internal/discriminator/biased_test.go b/internal/discriminator/biased_test.go index 8f2746b..3f2c51e 100644 --- a/internal/discriminator/biased_test.go +++ b/internal/discriminator/biased_test.go @@ -22,10 +22,10 @@ import ( // The biased thresholds. const ( - tagLimitPercent = "SWW_TAG_LIMIT_PERCENT" - asnBytesPercent = "SWW_ASN_BYTES_PERCENT" - countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" - unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" + tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT" + asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT" + countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT" + unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT" ) const ( @@ -34,7 +34,7 @@ const ( asnDEHalf = asnDE + ":50" asnDEQuarter = asnDE + ":25" countryDEHalf = "de:50" - // The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags. + // The same, as SWW_WAF_TAG_LIMIT_PERCENT names fromDE's tags. asnTagDEHalf = "asn=64496:50" asnTagDEQuarter = "asn=64496:25" countryTagDEHalf = "country=DE:50" @@ -240,7 +240,7 @@ func TestLowestPercentageApplies(t *testing.T) { noCountry, "25 from " + unknownLimitPercent, }, { - // SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100. + // SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100. "the AS number's, for a client without a country", map[string]string{tagLimitPercent: "asn=64500:25"}, noCountry, "25 from " + tagLimitPercent, @@ -505,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string } // percentText gives a limit percentage and the setting that gave it as -// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are +// text, such as "50 from SWW_WAF_TAG_LIMIT_PERCENT", or none when both are // left out. func percentText(percent *int64, setting string) string { switch { diff --git a/internal/discriminator/blocklists.go b/internal/discriminator/blocklists.go index fae5b0d..dedbb48 100644 --- a/internal/discriminator/blocklists.go +++ b/internal/discriminator/blocklists.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists +// The heuristic blocklists. It reads SWW_WAF_BLOCKLIST_ACTION, and the blocklists // through the reputation lists. import ( @@ -11,7 +11,7 @@ import ( ) // blocklists notes the blocklists that list the client, as noteListed does, -// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it +// and refuses the request when SWW_WAF_BLOCKLIST_ACTION is deny. Being limit, it // lowers the client's limits instead (see limitPercentages), and being log, it // does nothing more. Its figure is the blocklists that list the client. type blocklists struct{} @@ -35,7 +35,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict { // refusedByBlocklists reports whether the heuristic blocklists refuses a // client, given listedBy, the blocklists that list it: it does when one lists -// it and SWW_BLOCKLIST_ACTION is deny. +// it and SWW_WAF_BLOCKLIST_ACTION is deny. func (rq *request) refusedByBlocklists(listedBy []string) bool { return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny } diff --git a/internal/discriminator/bytelimits.go b/internal/discriminator/bytelimits.go index 6996383..48793e5 100644 --- a/internal/discriminator/bytelimits.go +++ b/internal/discriminator/bytelimits.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits +// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits // and biased thresholds through the limiter and limitPercentages. import ( diff --git a/internal/discriminator/bytelimits_test.go b/internal/discriminator/bytelimits_test.go index 2170b0d..2764b59 100644 --- a/internal/discriminator/bytelimits_test.go +++ b/internal/discriminator/bytelimits_test.go @@ -19,13 +19,13 @@ import ( // The byte limit settings. const ( - bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE" - bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR" - bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY" - bytesCount = "SWW_BYTES_COUNT" + bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE" + bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR" + bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY" + bytesCount = "SWW_WAF_BYTES_COUNT" ) -// The values of SWW_BYTES_COUNT. +// The values of SWW_WAF_BYTES_COUNT. const ( countResponse = "response" countRequest = "request" @@ -40,7 +40,7 @@ const ( // byteLimit is the byte limit these tests set, as a setting: a request // with a body and its answer, 100 bytes, go over it. byteLimit = "99" - // minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE. + // minuteBytes is limit_hit for SWW_WAF_BYTES_LIMIT_PER_MINUTE. minuteBytes = "minute_bytes" ) @@ -227,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.7" // in SWW_ALLOW_NETS - exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS + exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS ) s, _ := startWithAnswers(t, map[string]string{ diff --git a/internal/discriminator/client.go b/internal/discriminator/client.go index 4f72d24..d6555b0 100644 --- a/internal/discriminator/client.go +++ b/internal/discriminator/client.go @@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string { // clientGroup is the client a request is counted toward: its IPv4 // address, or its IPv6 group, the netblock its IPv6 address is in of the -// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser +// length SWW_WAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser // usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4. func (h *handler) clientGroup(addr netip.Addr) netip.Prefix { addr = addr.Unmap() diff --git a/internal/discriminator/coreruleset.go b/internal/discriminator/coreruleset.go index e043087..69f072e 100644 --- a/internal/discriminator/coreruleset.go +++ b/internal/discriminator/coreruleset.go @@ -1,7 +1,7 @@ package discriminator -// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS -// and SWW_WAF_ANOMALY_THRESHOLD. +// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE, +// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. import ( "context" @@ -14,13 +14,13 @@ import ( ) // coreRuleSet inspects the request with the Core Rule Set, unless -// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as -// pathExempt decides, and notes the rules it matched and its score in the -// log line, and the rules in the metrics. A score at or over -// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature, -// raises the core_rule_set_match alert, and in block mode refuses the request with 403, -// which is an offence its client's history counts. Its figures are the score -// and the threshold. +// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS +// exempts its path, as pathExempt decides, and notes the rules it matched and +// its score in the log line, and the rules in the metrics. A score at or over +// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag +// exploitsignature, raises the core_rule_set_match alert, and in block mode +// refuses the request with 403, which is an offence its client's history +// counts. Its figures are the score and the threshold. type coreRuleSet struct{} func (coreRuleSet) name() string { return "core_rule_set" } @@ -67,10 +67,10 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict { } // alertCoreRuleSetMatch raises the core_rule_set_match alert for the request, -// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its -// detail gives the rule ids, the score, the method and the path with the -// query, and, for a request that is not refused for it, the mode: detect, -// or observe in observe mode. +// which the Core Rule Set scored at result, at or over +// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the +// score, the method and the path with the query, and, for a request that is +// not refused for it, the mode: detect, or observe in observe mode. func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) { detail := map[string]any{ "rule_ids": result.RuleIDs, @@ -94,7 +94,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) { ASN: rq.line.ASN, ASName: rq.line.ASName, Country: rq.line.Country, - Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", - Detail: detail, + Reason: "scored by the Core Rule Set at or over " + + "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", + Detail: detail, }) } diff --git a/internal/discriminator/coreruleset_test.go b/internal/discriminator/coreruleset_test.go index b2196be..cef12ef 100644 --- a/internal/discriminator/coreruleset_test.go +++ b/internal/discriminator/coreruleset_test.go @@ -14,13 +14,13 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and +// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and // its two modes that inspect requests. const ( - wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD" - wafDisabledRules = "SWW_WAF_DISABLED_RULES" - wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" - wafBodyLimit = "SWW_WAF_BODY_LIMIT" + wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD" + wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES" + wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS" + wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT" block = "block" detect = "detect" ) @@ -28,7 +28,7 @@ const ( // formData is the type of a form's body. const formData = "application/x-www-form-urlencoded" -// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a +// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a // setting. const ( sizeLimit = 1 << 10 @@ -37,7 +37,7 @@ const ( // sqlInjection asks for / with an SQL injection in its query, which only // the Core Rule Set's rule 942100 matches, with a score of 5, the default -// SWW_WAF_ANOMALY_THRESHOLD. +// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. const sqlInjection = "/?id=1'%20OR%20'1'='1" // wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and @@ -177,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( ) { t.Parallel() - const allowed = "192.0.2.60" // in SWW_ALLOW_NETS + const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS s, _, _ := startWithClock(t, "", map[string]string{ wafMode: block, @@ -186,7 +186,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( rulesDir: writeRules(t, testRules), }) - // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS + // A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS // exempts, are not inspected. line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) wantCoreRuleSet(t, line, nil) @@ -205,7 +205,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( requestlog.ActionBlockedByBlockRule) wantCoreRuleSet(t, line, nil) - // With SWW_WAF_MODE off, no request is. + // With SWW_WAF_CORE_RULE_SET_MODE off, no request is. s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) wantCoreRuleSet(t, line, nil) @@ -522,8 +522,9 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) { Host: appHost, Client: netip.MustParseAddr(client), Netblock: netip.MustParsePrefix(client + "/32"), - Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", - Detail: detail, + Reason: "scored by the Core Rule Set at or over " + + "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", + Detail: detail, }) if queue.Suppressed() != 1 { diff --git a/internal/discriminator/countries.go b/internal/discriminator/countries.go index 826f737..c166973 100644 --- a/internal/discriminator/countries.go +++ b/internal/discriminator/countries.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. +// The heuristic countries. It reads SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES. import ( "context" @@ -10,7 +10,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and +// countries refuses a request when SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set and // does not hold the client's country as it was looked up. A client without a // country, or whose country cannot be found, is refused. Its figures are the // country and the setting that refused it. @@ -29,7 +29,7 @@ func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict { return &verdict{ action: requestlog.ActionCountryDenied, figures: map[string]any{ - "country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", + "country": country, "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", }, } } diff --git a/internal/discriminator/countries_test.go b/internal/discriminator/countries_test.go index 89311a2..f17c7e1 100644 --- a/internal/discriminator/countries_test.go +++ b/internal/discriminator/countries_test.go @@ -38,7 +38,7 @@ func TestCountryLists(t *testing.T) { []string{fromKP, unplaced}, }, { - "with a country in SWW_TAG_DENY", + "with a country in SWW_WAF_TAG_DENY", map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"}, []string{fromKP, unplaced}, }, @@ -100,11 +100,11 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) { line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied) wantHeuristic(t, line, "countries", - `{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) + `{"country": "KP", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied) wantHeuristic(t, line, "countries", - `{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) + `{"country": "", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`) } func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { @@ -262,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) { action string }{ { - "not in SWW_ALLOW_NETS", "", + "not in SWW_WAF_ALLOW_NETS", "", http.StatusForbidden, requestlog.ActionCountryDenied, }, { - "in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8", + "in SWW_WAF_ALLOW_NETS", "10.0.0.7,fd00::/8", http.StatusOK, requestlog.ActionForward, }, } { @@ -296,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) { // startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP, // each in an AS of its own, and no other address. It returns its URL, and // what returns the addresses it has been asked about. A test that needs -// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an +// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an // hour, whether or not a request waits for the answer: on the default, a // hold-up of the test process can abandon the request to the stand-in, and // leave the client unknown. diff --git a/internal/discriminator/crowdsec.go b/internal/discriminator/crowdsec.go index 6e1473f..85aa825 100644 --- a/internal/discriminator/crowdsec.go +++ b/internal/discriminator/crowdsec.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions +// The heuristic crowdsec. It reads SWW_WAF_CROWDSEC_LAPI_URL, and the decisions // through the reputation lists. import ( diff --git a/internal/discriminator/crowdsec_test.go b/internal/discriminator/crowdsec_test.go index 8a63627..d488b63 100644 --- a/internal/discriminator/crowdsec_test.go +++ b/internal/discriminator/crowdsec_test.go @@ -18,8 +18,8 @@ import ( // test puts in the copy of its decision list, at decisionsURL, that it // needs, as reputation.json would at start. const ( - crowdSecURL = "SWW_CROWDSEC_LAPI_URL" - crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" + crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL" + crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY" lapi = "http://crowdsec.example:8080" decisionsURL = lapi + "/v1/decisions" bouncerKey = "crowdsec-key-0123456789abcdef" diff --git a/internal/discriminator/denynets.go b/internal/discriminator/denynets.go index 41002e6..f1cb909 100644 --- a/internal/discriminator/denynets.go +++ b/internal/discriminator/denynets.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic deny_nets. It reads SWW_DENY_NETS. +// The heuristic deny_nets. It reads SWW_WAF_DENY_NETS. import ( "context" @@ -9,7 +9,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of +// denyNets refuses a client in SWW_WAF_DENY_NETS. Its figure is the netblock of // the setting the client is in. type denyNets struct{} diff --git a/internal/discriminator/discriminator.go b/internal/discriminator/discriminator.go index 1f78d37..d00709d 100644 --- a/internal/discriminator/discriminator.go +++ b/internal/discriminator/discriminator.go @@ -40,7 +40,7 @@ const ( // headers and body. DispositionDeny = "deny" // DispositionClose tells rproxy to close the visitor's connection - // without an answer, which SWW_BAN_RESPONSE asks for when it is close. + // without an answer, which SWW_WAF_BAN_RESPONSE asks for when it is close. // discriminator closing the question's connection instead would read as // discriminator being down, and rproxy would then let the request through. DispositionClose = "close" @@ -302,7 +302,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request { } // allow answers a question with DispositionAllow and, while -// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country. +// SWW_WAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country. func (rq *request) allow() { rq.allowed = true @@ -328,7 +328,7 @@ func (rq *request) deny(r refusal) { rq.refused = &r if r.status == 0 { - // SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's + // SWW_WAF_BAN_RESPONSE is close. Written past rq.out, since the log line's // status stays 0: the visitor is sent nothing. rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.ResponseWriter.WriteHeader(http.StatusOK) diff --git a/internal/discriminator/discriminator_test.go b/internal/discriminator/discriminator_test.go index 44a3d2a..1de0569 100644 --- a/internal/discriminator/discriminator_test.go +++ b/internal/discriminator/discriminator_test.go @@ -39,7 +39,7 @@ const ( // reportBodyLimit is the longest body SPEC.md lets a report have. reportBodyLimit = 1 << 20 // anomalyClientPerMinute is the anomaly threshold these tests set. - anomalyClientPerMinute = "SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" + anomalyClientPerMinute = "SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" ) // The fields of a report, as "discriminator" in SPEC.md names them. @@ -181,7 +181,7 @@ func (s *Discriminator) history() ratelimit.History { } // metrics returns what the metrics endpoint answers, for a server started with -// the token as SWW_METRICS_TOKEN. +// the token as SWW_WAF_METRICS_TOKEN. func (s *Discriminator) metrics() string { s.t.Helper() @@ -1486,7 +1486,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) { func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { t.Parallel() - s := startDiscriminator(t, "", map[string]string{"SWW_LOG_LEVEL": "error"}) + s := startDiscriminator(t, "", map[string]string{"SWW_WAF_LOG_LEVEL": "error"}) // A refused question would write a process warning, held back at error. req := s.question(siteA, client, "/") diff --git a/internal/discriminator/dnsbl.go b/internal/discriminator/dnsbl.go index f00fd10..52e9494 100644 --- a/internal/discriminator/dnsbl.go +++ b/internal/discriminator/dnsbl.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones' +// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones' // verdicts through the DNSBL checker. import ( @@ -14,7 +14,7 @@ import ( // dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed // does, adds the tag dnsbl= for each, the zone's key masked, and refuses -// the request when SWW_REPUTATION_ACTION is deny. Being +// the request when SWW_WAF_REPUTATION_ACTION is deny. Being // limit, it lowers the client's limits instead (see limitPercentages), and // being log, it does nothing more. A zone without a verdict on the client is // asked about it in the background, and the request does not wait for the diff --git a/internal/discriminator/heuristics.go b/internal/discriminator/heuristics.go index 605d4a4..90ea52e 100644 --- a/internal/discriminator/heuristics.go +++ b/internal/discriminator/heuristics.go @@ -37,7 +37,7 @@ const ( ) // The lists of heuristics, each in the order its heuristics run. A client in -// SWW_ALLOW_NETS skips them all. +// SWW_WAF_ALLOW_NETS skips them all. // beforeLookup are the heuristics that run before the client's AS number and // country are looked up, so that a client they refuse is not looked up. diff --git a/internal/discriminator/lookup.go b/internal/discriminator/lookup.go index 73558b2..85bc7ed 100644 --- a/internal/discriminator/lookup.go +++ b/internal/discriminator/lookup.go @@ -12,7 +12,7 @@ import ( ) // The headers in which an answer of allow gives rproxy the client's AS number -// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes +// and country, for the app, while SWW_WAF_ADD_LOOKUP_HEADERS is set. Go writes // every header name in this form, as it sends it and as it receives it, so // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive. const ( @@ -23,7 +23,7 @@ const ( // lookUp looks up the client's AS number and country, in the lookup // database or through GeoJS, and notes them for the log line and as the // request's asn= and country= tags, unless -// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or +// SWW_WAF_LOOKUP_SOURCE is off or the client is on a private, loopback or // link-local address, which no lookup can place. The lookup database // answers at once. With GeoJS, while a setting needs the answer, such as a // country list or a biased threshold, a new client's request waits for it, diff --git a/internal/discriminator/lookup_test.go b/internal/discriminator/lookup_test.go index 4e575fc..a7882c5 100644 --- a/internal/discriminator/lookup_test.go +++ b/internal/discriminator/lookup_test.go @@ -20,7 +20,7 @@ import ( // and country. type asnAndCountry struct{ asn, asName, country string } -// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the +// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the // lookup database. const fileSource = "file" diff --git a/internal/discriminator/metrics_test.go b/internal/discriminator/metrics_test.go index 457ed1c..76efcda 100644 --- a/internal/discriminator/metrics_test.go +++ b/internal/discriminator/metrics_test.go @@ -17,9 +17,9 @@ import ( ) const ( - metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name - metricsTopN = "SWW_METRICS_TOP_N" - // token is the SWW_METRICS_TOKEN the tests set, and bearer how a + metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name + metricsTopN = "SWW_WAF_METRICS_TOP_N" + // token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a // request carries it. token = "0123456789abcdef0123456789abcdef" bearer = "Bearer " + token @@ -113,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWW_DENY_NETS + const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS s, clk, _ := startWithClock(t, "", map[string]string{ metricsToken: token, @@ -124,7 +124,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { maxBanDuration: "2h", }) - // SWW_BAN_RESPONSE=close sends no status at all. + // SWW_WAF_BAN_RESPONSE=close sends no status at all. s.get(denied, 0, requestlog.ActionClientDenied) // A first broken limit bans for an hour. @@ -145,7 +145,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0) // A limit broken again right after would ban for three hours, longer - // than SWW_MAX_BAN_DURATION, so the ban is permanent. + // than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent. s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, 0, requestlog.ActionClientIsRateLimited) @@ -327,13 +327,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { tagDeny: denyKP, }) - // GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does + // GeoJS fails, so the client has no country= tag, and SWW_WAF_TAG_DENY does // not refuse it. req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header.Set(forwardedFor, fromDE) wantStatus(t, do(t, req), http.StatusOK) - // The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so + // The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so // GeoJS's failure can come after its request has ended. deadline := time.Now().Add(waitLimit) metrics := scrape(t, server.Addr) diff --git a/internal/discriminator/observe_test.go b/internal/discriminator/observe_test.go index 9d44220..4782288 100644 --- a/internal/discriminator/observe_test.go +++ b/internal/discriminator/observe_test.go @@ -12,7 +12,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// observe is the value of SWW_MODE for observe mode. +// observe is the value of SWW_WAF_MODE for observe mode. const observe = "observe" func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) { @@ -118,12 +118,12 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { t.Parallel() const ( - denied = "192.0.2.50" // in SWW_DENY_NETS + denied = "192.0.2.50" // in SWW_WAF_DENY_NETS banned = otherClient // under a ban read from bans.json ) for _, tc := range []struct { - setting string // "" leaves SWW_MODE at its default + setting string // "" leaves SWW_WAF_MODE at its default observe bool }{ {"", false}, diff --git a/internal/discriminator/proxy.go b/internal/discriminator/proxy.go index 3e6215c..271808b 100644 --- a/internal/discriminator/proxy.go +++ b/internal/discriminator/proxy.go @@ -34,15 +34,15 @@ const ownEndpointPrefix = "/_sww/" const HealthPath = "/_sww/healthz" // MetricsPath is where the metrics are, for a request that carries -// SWW_METRICS_TOKEN. +// SWW_WAF_METRICS_TOKEN. const MetricsPath = "/_sww/metrics" // BansPath is where an admin lists and adds bans, and, followed by / and -// a client's address, lifts them, with SWW_ADMIN_TOKEN. +// a client's address, lifts them, with SWW_WAF_ADMIN_TOKEN. const BansPath = "/_sww/bans" // ClientsPath is where an admin asks what discriminator knows of a client, by -// the client's address after it, with SWW_ADMIN_TOKEN. +// the client's address after it, with SWW_WAF_ADMIN_TOKEN. const ClientsPath = "/_sww/clients/" // Params are what New needs. @@ -55,16 +55,16 @@ type Params struct { // ProcessLog receives the process's own messages. ProcessLog *slog.Logger // GeoJSURL is where clients' AS numbers and countries are looked up - // while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL. + // while SWW_WAF_LOOKUP_SOURCE is geojs, normally lookup.URL. GeoJSURL string // AbuseIPDBURL is where clients are checked with AbuseIPDB while - // SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. + // SWW_WAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. AbuseIPDBURL string // LookupFile is the lookup database they are looked up in while - // SWW_LOOKUP_SOURCE is file, and nil otherwise. + // SWW_WAF_LOOKUP_SOURCE is file, and nil otherwise. LookupFile *lookup.File // CoreRuleSet is what NewCoreRuleSet returns: what inspects requests - // while SWW_WAF_MODE is not off, and nil while it is. + // while SWW_WAF_CORE_RULE_SET_MODE is not off, and nil while it is. CoreRuleSet *coreruleset.CoreRuleSet // Now tells the time by which requests are counted for the rate // limits, bans are made and run out, and GeoJS's answers are kept, @@ -77,17 +77,17 @@ type Params struct { // permanent, for each count over an anomaly threshold, for each request // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // AbuseIPDB lists, for each request the Core Rule Set scores at or over - // SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list + // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // or the day's AbuseIPDB checks used up. Alerts *alerts.Queue } // Server is the server discriminator runs, with the parts whose state the state -// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the +// files keep, the lookup database, nil unless SWW_WAF_LOOKUP_SOURCE is file, the // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while -// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only. +// SWW_WAF_ROUTEWATCH_URL is unset, keeps its answers in memory only. type Server struct { *http.Server @@ -185,7 +185,7 @@ func newHandler(params Params) *handler { // newReputation returns the lists fetched from URLs, the DNSBL zones' // verdicts and AbuseIPDB's scores, as the settings in params name them, // with none fetched, asked for or checked yet, and adds their metrics to -// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set. +// m, AbuseIPDB's while SWW_WAF_ABUSEIPDB_KEY is set. func newReputation( params Params, m *metrics.Metrics, ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { @@ -218,11 +218,11 @@ func newReputation( return lists, dnsbl, abuseIPDB } -// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL, -// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies -// up to SWW_WAF_BODY_LIMIT, with the exclusions in -// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It -// reads at most QuestionBodyMaxBytes less one byte: a question's body of +// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, +// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies +// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in +// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_CORE_RULE_SET_MODE is +// off. It reads at most QuestionBodyMaxBytes less one byte: a question's body of // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is // an error in every mode. While the mode is off, a set file is loaded with the @@ -238,7 +238,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) { ExclusionsFile: cfg.CoreRuleSetExclusionsFile, }) if err != nil { - return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) + return nil, fmt.Errorf("SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) } if cfg.WAFMode == config.WAFModeOff { diff --git a/internal/discriminator/proxy_test.go b/internal/discriminator/proxy_test.go index e98f866..01da0a9 100644 --- a/internal/discriminator/proxy_test.go +++ b/internal/discriminator/proxy_test.go @@ -17,6 +17,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/requestlog" @@ -55,35 +56,35 @@ const ( // The settings the tests set. const ( - mode = "SWW_MODE" - trustedProxies = "SWRP_TRUSTED_PROXIES" - ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX" - maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS" - allowNets = "SWW_ALLOW_NETS" - rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" - denyNets = "SWW_DENY_NETS" - rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE" - rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" - rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS" - lookupSource = "SWW_LOOKUP_SOURCE" - lookupDBPath = "SWW_LOOKUP_DB_PATH" - lookupTimeout = "SWW_LOOKUP_TIMEOUT" - addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS" - tagDeny = "SWW_TAG_DENY" - // denyKP is the value of SWW_TAG_DENY that refuses fromKP's country. + mode = "SWW_WAF_MODE" + trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES" + ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX" + maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS" + allowNets = "SWW_WAF_ALLOW_NETS" + rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS" + denyNets = "SWW_WAF_DENY_NETS" + rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE" + rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY" + rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS" + lookupSource = "SWW_WAF_LOOKUP_SOURCE" + lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH" + lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT" + addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS" + tagDeny = "SWW_WAF_TAG_DENY" + // denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country. denyKP = "country=KP" - allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" - banResponse = "SWW_BAN_RESPONSE" - limitBanDuration = "SWW_LIMIT_BAN_DURATION" - limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW" - maxBanDuration = "SWW_MAX_BAN_DURATION" - maxBans = "SWW_MAX_BANS" - banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX" - instanceName = "SWW_INSTANCE_NAME" - logRequestHeaders = "SWW_LOG_REQUEST_HEADERS" - attackBanDuration = "SWW_ATTACK_BAN_DURATION" - rulesDir = "SWW_RULES_DIR" - wafMode = "SWW_WAF_MODE" + allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES" + banResponse = "SWW_WAF_BAN_RESPONSE" + limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION" + limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW" + maxBanDuration = "SWW_WAF_MAX_BAN_DURATION" + maxBans = "SWW_WAF_MAX_BANS" + banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX" + instanceName = "SWW_WAF_INSTANCE_NAME" + logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS" + attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION" + rulesDir = "SWW_WAF_RULES_DIR" + wafMode = "SWW_WAF_CORE_RULE_SET_MODE" ) // off is the value that switches a setting off. @@ -296,8 +297,8 @@ func startProxyWithGeoJS( // startProxyWithClock is startProxyWithGeoJS with requests counted and // bans made by the time now tells, and returns discriminator's server as well, whose -// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty -// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app, +// Addr is where discriminator listens. Unless env sets SWW_WAF_RULES_DIR, it is an +// empty directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app, // the label instance of every metric. func startProxyWithClock( t *testing.T, appURL, geojsURL string, now func() time.Time, @@ -347,25 +348,21 @@ func serve(t *testing.T, server *discriminator.Server) { // startRProxy starts rproxy in front of the app at appURL, asking the discriminator // at discriminatorAddr, with the settings in env on top of the defaults, and returns -// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless -// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide. +// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless +// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide. func startRProxy( t *testing.T, discriminatorAddr, appURL string, env map[string]string, ) string { t.Helper() settings := map[string]string{ - "SWRP_WAF_BACKEND": "http://" + discriminatorAddr, - "SWRP_UPSTREAM_URL": appURL, - "SWRP_TIMEOUT_MS": "60000", + "SWW_RPROXY_WAF_BACKEND": "http://" + discriminatorAddr, + "SWW_RPROXY_UPSTREAM_URL": appURL, + "SWW_RPROXY_TIMEOUT_MS": "60000", } maps.Copy(settings, env) - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - value, ok := settings[name] - - return value, ok - }) + cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings)) if err != nil { t.Fatalf("settings %v: %v", settings, err) } @@ -397,11 +394,11 @@ func startRProxy( // settings in env make it. No alert is sent from the queue: they wait in it, // for the test to look at. // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and -// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup -// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB -// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless -// env sets it, so that only the tests of the Core Rule Set have their requests -// inspected by it. +// SWW_WAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup +// database SWW_WAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB +// at abuseIPDBURL while env sets SWW_WAF_ABUSEIPDB_KEY. +// SWW_WAF_CORE_RULE_SET_MODE is off unless env sets it, so that only the tests of the +// Core Rule Set have their requests inspected by it. func newDiscriminator( t *testing.T, geojsURL string, now func() time.Time, env map[string]string, ) (*discriminator.Server, *output, *alerts.Queue) { @@ -426,11 +423,7 @@ func newDiscriminatorWith( maps.Copy(settings, env) - cfg, err := config.FromEnvironment(func(name string) (string, bool) { - value, ok := settings[name] - - return value, ok - }) + cfg, err := config.FromEnvironment(configtest.Viper(settings)) if err != nil { t.Fatalf("settings %v: %v", settings, err) } diff --git a/internal/discriminator/ratelimits.go b/internal/discriminator/ratelimits.go index 482e42a..b948946 100644 --- a/internal/discriminator/ratelimits.go +++ b/internal/discriminator/ratelimits.go @@ -1,7 +1,7 @@ package discriminator -// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS, -// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and +// The heuristic rate_limits. It reads SWW_WAF_RATE_LIMIT_EXEMPT_NETS, +// SWW_WAF_RATE_LIMIT_EXEMPT_PATHS and SWW_WAF_BAN_RESPONSE, and the rate limits and // biased thresholds through the limiter and limitPercentages. import ( @@ -59,8 +59,8 @@ func (rq *request) overAWholeRateLimit(now time.Time) bool { } // countedByRateLimits reports whether the rate limits count the request: its -// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt -// under SWW_RATE_LIMIT_EXEMPT_PATHS. +// client is not in SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt +// under SWW_WAF_RATE_LIMIT_EXEMPT_PATHS. func (rq *request) countedByRateLimits() bool { cfg := rq.h.config diff --git a/internal/discriminator/ratelimits_test.go b/internal/discriminator/ratelimits_test.go index 0e3a8b0..40a4213 100644 --- a/internal/discriminator/ratelimits_test.go +++ b/internal/discriminator/ratelimits_test.go @@ -10,7 +10,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's +// minute is the window of SWW_WAF_RATE_LIMIT_PER_MINUTE, as a log line's // limit_hit names it. const minute = "minute" @@ -86,7 +86,7 @@ func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { t.Parallel() - // With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two + // With SWW_WAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two // /64s of one /48, are one client, and the second's request breaks the // limit; the third, in the next /48, is another client. const ( @@ -129,7 +129,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWW_DENY_NETS + const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS geojsURL, _ := startGeoJS(t) s, _, server := startWithClock(t, geojsURL, map[string]string{ @@ -163,7 +163,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { s.request(client, "/assets", http.StatusForbidden, requestlog.ActionClientIsRateLimited) - // A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a + // A ban, SWW_WAF_DENY_NETS and SWW_WAF_TAG_DENY still refuse a path under a // prefix. s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionTemporarilyBanned) diff --git a/internal/discriminator/refusallimit.go b/internal/discriminator/refusallimit.go index 1856811..4102785 100644 --- a/internal/discriminator/refusallimit.go +++ b/internal/discriminator/refusallimit.go @@ -1,7 +1,7 @@ package discriminator -// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and -// SWW_BAN_RESPONSE. +// The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and +// SWW_WAF_BAN_RESPONSE. import ( "context" @@ -14,8 +14,8 @@ import ( // refusalLimit counts the request once it has been answered, if discriminator // refused it after a rule file match, a trap path or a Core Rule Set match, and // in observe mode if enforce mode would have: more than -// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break -// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the +// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break +// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the // heuristics skip, is not counted, and nothing is while the threshold is off. // Its figures are the window, the count and the limit. type refusalLimit struct{} diff --git a/internal/discriminator/refusallimit_test.go b/internal/discriminator/refusallimit_test.go index c3142a5..fb4e1e7 100644 --- a/internal/discriminator/refusallimit_test.go +++ b/internal/discriminator/refusallimit_test.go @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" +const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD" // refused is a request the tests here send, which discriminator refuses after a rule // file match. @@ -49,7 +49,7 @@ func (r refused) send(s *sender, from string) logLine { } // startForErrorBurst is startWithClock with testRules, the metrics token and -// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env. +// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. func startForErrorBurst( t *testing.T, threshold string, env map[string]string, ) (*sender, *clock, *discriminator.Server) { @@ -290,15 +290,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.60" // in SWW_ALLOW_NETS - exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS + exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS ) s, _, _ := startForErrorBurst(t, "1", map[string]string{ allowNets: allowed, rateLimitExemptNets: exempt, }) - // A client in SWW_ALLOW_NETS is not refused, and so not counted. + // A client in SWW_WAF_ALLOW_NETS is not refused, and so not counted. for range 3 { s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) } diff --git a/internal/discriminator/reputation.go b/internal/discriminator/reputation.go index efabef6..709c1b8 100644 --- a/internal/discriminator/reputation.go +++ b/internal/discriminator/reputation.go @@ -8,7 +8,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/bans" ) -// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that +// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that // refuses the requests of a client a source lists. const deny = "deny" diff --git a/internal/discriminator/reputation_test.go b/internal/discriminator/reputation_test.go index 153ef50..561a29c 100644 --- a/internal/discriminator/reputation_test.go +++ b/internal/discriminator/reputation_test.go @@ -23,12 +23,12 @@ import ( // The reputation settings. const ( - blocklistURLs = "SWW_BLOCKLIST_URLS" - blocklistAction = "SWW_BLOCKLIST_ACTION" - asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL" + blocklistURLs = "SWW_WAF_BLOCKLIST_URLS" + blocklistAction = "SWW_WAF_BLOCKLIST_ACTION" + asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL" ) -// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION: +// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION: // limitHalf gives a listed client half of every limit, and limitQuarter a // quarter. const ( @@ -178,7 +178,7 @@ func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) { loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) // fromKP's country refuses it before the list is looked at, and fromDE, - // in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor + // in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted, nor // alerted. wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) @@ -277,7 +277,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) }, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, { - "SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits", + "SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits", map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none, }, } { @@ -367,9 +367,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( // The DNSBL settings. const ( - dnsblZones = "SWW_DNSBL_ZONES" - dnsblResolver = "SWW_DNSBL_RESOLVER" - reputationAction = "SWW_REPUTATION_ACTION" + dnsblZones = "SWW_WAF_DNSBL_ZONES" + dnsblResolver = "SWW_WAF_DNSBL_RESOLVER" + reputationAction = "SWW_WAF_REPUTATION_ACTION" ) // The DNSBL zones these tests name, which are never asked about the @@ -425,7 +425,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { for _, from := range []string{fromDE, fromKP} { for i := range 3 { line := s.get(from, tc.statuses[i], tc.actions[i]) - // In the order SWW_DNSBL_ZONES names them. + // In the order SWW_WAF_DNSBL_ZONES names them. wantReputation(t, line, listedBy[from]...) wantPercent(t, "rate_limit_percent", line.RateLimitPercent, line.RateLimitPercentSetting, tc.percent) @@ -472,7 +472,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) { loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}) // The blocklist refuses fromKP before its verdict is looked at, and - // fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted + // fromDE, in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted // for the zone, nor alerted, nor asked about. wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied), dropURL) @@ -707,7 +707,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) { // The AbuseIPDB settings, and accountKey, the key the tests set. const ( - abuseIPDBKey = "SWW_ABUSEIPDB_KEY" + abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY" accountKey = "abuseipdb-key-0123456789abcdef" ) @@ -898,7 +898,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey, reputationAction: tc.action, }) - // At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. + // At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. loadScores(server, map[string]int64{fromDE: 75, fromKP: 74}) for i := range 3 { @@ -951,7 +951,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted( // The alert is made as a DNSBL zone's is, with the score besides. waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || - waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" || + waiting[0].Reason != "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE" || waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) || queue.Suppressed() != 1 { t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+ @@ -1022,7 +1022,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { abuseIPDBKey: accountKey, reputationAction: actionLog, }) // Every source lists client, and none otherClient, whose score is - // under SWW_ABUSEIPDB_MIN_SCORE, 75 by default. + // under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default. loadLists(t, server, map[string][]string{dropURL: {client}}) loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil}) loadScores(server, map[string]int64{client: score, otherClient: 74}) @@ -1071,7 +1071,7 @@ func verdictsFetched() time.Time { } // loadVerdicts puts into server's DNSBL, for each client listedBy names, -// a verdict of each zone SWW_DNSBL_ZONES names, fetched at +// a verdict of each zone SWW_WAF_DNSBL_ZONES names, fetched at // verdictsFetched, as reputation.json would at start: one that lists the // client from each zone listedBy gives for it, and one that does not from // each other zone. diff --git a/internal/discriminator/request.go b/internal/discriminator/request.go index e2a4407..a397f71 100644 --- a/internal/discriminator/request.go +++ b/internal/discriminator/request.go @@ -44,7 +44,7 @@ type decision struct { lookedUp bool lookupAnswer lookup.Answer // skippedLookupWait is true for a request that did not wait for GeoJS and - // found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it, + // found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it, // so that not waiting never lowers its client's limits. skippedLookupWait bool // counted is true for a request the rate limits counted, whose bytes @@ -170,7 +170,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string { // check is the one place where a request can be refused. It returns nil to let // the request through. The heuristics of checkClient decide: a request they -// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core +// refuse is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core // Rule Set, or 431 for the header limits. In observe mode nothing is refused, // and the log line names what enforce mode would have done. ctx is the // request's own context. @@ -200,7 +200,7 @@ func (rq *request) check(ctx context.Context) *refusal { // checkClient runs the heuristics of beforeLookup on the request, then looks // up the client's AS number and country, then runs those of afterLookup. It // returns the first verdict, or nil when none gives one. A client in -// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's +// SWW_WAF_ALLOW_NETS skips them all, and is not looked up. ctx is the request's // own context. func (rq *request) checkClient(ctx context.Context) *verdict { if isInside(rq.client, rq.h.config.AllowNets) { @@ -224,7 +224,7 @@ func (rq *request) checkClient(ctx context.Context) *verdict { } // pathExempt reports whether r is exempt under prefixes, -// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its +// SWW_WAF_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS: whether its // path as sent, the path the app receives, not percent-decoded, starts // with one of prefixes, so that /%61ssets/x is not under /assets/ for an // app whose router matches the path as received. A request whose decoded @@ -326,9 +326,9 @@ func (rq *request) addToHistory() { // countAnomalies counts the request, which has ended, and its bytes, as // countedBytes gives them, for the anomaly thresholds, whatever was done -// with it: a request refused, one from a client in SWW_ALLOW_NETS or -// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in -// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its +// with it: a request refused, one from a client in SWW_WAF_ALLOW_NETS or +// SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in +// SWW_WAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its // client's AS number when answerAtTheEnd gives one. With every anomaly // threshold off, the default, it does nothing. func (rq *request) countAnomalies() { diff --git a/internal/discriminator/requestlog_test.go b/internal/discriminator/requestlog_test.go index 7c04695..5f22fba 100644 --- a/internal/discriminator/requestlog_test.go +++ b/internal/discriminator/requestlog_test.go @@ -19,7 +19,7 @@ import ( const ( // requestIDHeader carries the request's id. requestIDHeader = "X-Request-ID" - // instance is the SWW_INSTANCE_NAME a test sets. + // instance is the SWW_WAF_INSTANCE_NAME a test sets. instance = "fsn1app1/blog" // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // limits count it as. @@ -196,7 +196,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWW_DENY_NETS + const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { // The pause sets the times apart; a hold-up of the test only lengthens diff --git a/internal/discriminator/routewatchtags.go b/internal/discriminator/routewatchtags.go index 33b469d..603d4e0 100644 --- a/internal/discriminator/routewatchtags.go +++ b/internal/discriminator/routewatchtags.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL. +// The heuristic routewatch_tags. It reads SWW_WAF_ROUTEWATCH_URL. import ( "context" @@ -13,9 +13,10 @@ import ( // routeWatchTags adds to the request the tags asn= and prefix= // from what routewatch answered about the client. It refuses nothing, and the // request does not wait for routewatch. A request that gets no tags from it has -// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL -// is unset it does nothing. Answers are kept by client and asked for by the -// request's address, and a route without an origin AS gives only the prefix= tag. +// the reason in the log line's routewatch_tags_missing. While +// SWW_WAF_ROUTEWATCH_URL is unset it does nothing. Answers are kept by client and +// asked for by the request's address, and a route without an origin AS gives only +// the prefix= tag. type routeWatchTags struct{} func (routeWatchTags) name() string { return "routewatch_tags" } diff --git a/internal/discriminator/routewatchtags_test.go b/internal/discriminator/routewatchtags_test.go index 33327c9..c5dedf1 100644 --- a/internal/discriminator/routewatchtags_test.go +++ b/internal/discriminator/routewatchtags_test.go @@ -14,10 +14,10 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field +// routeWatchURL is SWW_WAF_ROUTEWATCH_URL, and tagsMissing the log line's field // that says why a request got no tags from routewatch. const ( - routeWatchURL = "SWW_ROUTEWATCH_URL" + routeWatchURL = "SWW_WAF_ROUTEWATCH_URL" tagsMissing = "routewatch_tags_missing" fromV6 = "2001:db8:1::5" prefixV6 = "2001:db8::/32" @@ -137,7 +137,7 @@ func TestUnsetRouteWatchChangesNothing(t *testing.T) { s, _, server := startWithClock(t, "", map[string]string{lookupSource: off}) if server.RouteWatch != nil { - t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset") + t.Error("RouteWatch is set while SWW_WAF_ROUTEWATCH_URL is unset") } line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) diff --git a/internal/discriminator/rulefiles.go b/internal/discriminator/rulefiles.go index 1422a95..aad18e8 100644 --- a/internal/discriminator/rulefiles.go +++ b/internal/discriminator/rulefiles.go @@ -1,7 +1,7 @@ package discriminator // The heuristic rule_files. It reads the rules of the rule files, which -// SWW_RULES_DIR and SWW_RULES_ENABLED name. +// SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name. import ( "context" diff --git a/internal/discriminator/rulefiles_test.go b/internal/discriminator/rulefiles_test.go index dd8af4d..5625c21 100644 --- a/internal/discriminator/rulefiles_test.go +++ b/internal/discriminator/rulefiles_test.go @@ -34,14 +34,14 @@ func TestEachRuleAction(t *testing.T) { line := s.get(client, http.StatusOK, requestlog.ActionForward) wantRuleIDs(t, line, "noted") - // A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and + // A block rule refuses with 403, whatever SWW_WAF_BAN_RESPONSE is, and // bans no one. line = s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionBlockedByBlockRule) wantRuleIDs(t, line, "noted", "blocked") s.get(client, http.StatusOK, requestlog.ActionForward) - // A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for + // A ban rule refuses with SWW_WAF_BAN_RESPONSE, and bans the client for // seven days, the default. line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionTemporarilyBanned) @@ -132,7 +132,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { attackBanDuration: "1h", }) - // The first probe bans for SWW_ATTACK_BAN_DURATION. + // The first probe bans for SWW_WAF_ATTACK_BAN_DURATION. line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned) if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { @@ -155,8 +155,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.60" // in SWW_ALLOW_NETS - exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS + exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS ) s, _, server := startWithClock(t, "", map[string]string{ @@ -166,7 +166,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { rateLimitPerMinute: "1", }) - // A client in SWW_ALLOW_NETS is not checked. + // A client in SWW_WAF_ALLOW_NETS is not checked. line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) wantRuleIDs(t, line) diff --git a/internal/discriminator/staticlists_test.go b/internal/discriminator/staticlists_test.go index 04643da..53ba5bb 100644 --- a/internal/discriminator/staticlists_test.go +++ b/internal/discriminator/staticlists_test.go @@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) { calls.Add(1) }) geojsURL, asked := startGeoJS(t) - // fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which + // fromKP is in SWW_WAF_ALLOW_NETS, and in SWW_WAF_DENY_NETS too, which // comes after it. addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ trustedProxies: trustLocalhost, @@ -36,7 +36,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) { rateLimitPerMinute: "1", }) - // Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a + // Neither SWW_WAF_DENY_NETS, SWW_WAF_TAG_DENY nor the limit of one request a // minute refuses the client, and its country is not looked up. wantAnswers(t, addr, out, []sentRequest{ {fromKP, http.StatusOK, requestlog.ActionForward}, @@ -147,7 +147,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) { // listedAddr's requests are neither refused nor counted, so the first // request from unlistedAddr is within the limit of one a minute. - // SWW_TAG_DENY still refuses an exempt client. + // SWW_WAF_TAG_DENY still refuses an exempt client. wantAnswers(t, addr, out, []sentRequest{ {listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward}, diff --git a/internal/discriminator/tagdeny.go b/internal/discriminator/tagdeny.go index b9a349d..730f11e 100644 --- a/internal/discriminator/tagdeny.go +++ b/internal/discriminator/tagdeny.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic tag_deny. It reads SWW_TAG_DENY. +// The heuristic tag_deny. It reads SWW_WAF_TAG_DENY. import ( "context" @@ -10,7 +10,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is +// tagDeny refuses a request that carries a tag in SWW_WAF_TAG_DENY. Its figure is // the first such tag, in sorted order. type tagDeny struct{} diff --git a/internal/discriminator/tagdeny_test.go b/internal/discriminator/tagdeny_test.go index 8222f43..92dc314 100644 --- a/internal/discriminator/tagdeny_test.go +++ b/internal/discriminator/tagdeny_test.go @@ -51,7 +51,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) { const mixedCaseZone = "DNSBL.example" - // The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it. + // The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it. s, server, _ := startWithLookups(t, map[string]string{ dnsblZones: mixedCaseZone, dnsblResolver: noResolver, reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone, diff --git a/internal/discriminator/taglists.go b/internal/discriminator/taglists.go index dc3aba8..6ac7506 100644 --- a/internal/discriminator/taglists.go +++ b/internal/discriminator/taglists.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through +// The heuristic tag_lists. It reads the address lists of SWW_WAF_TAG_LISTS through // the reputation lists. import ( @@ -8,7 +8,7 @@ import ( "time" ) -// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that +// tagLists adds to the request one tag for each list of SWW_WAF_TAG_LISTS that // holds the client, the tag being the list's name. It refuses nothing, and runs // first so that every refusal's log line carries these tags. type tagLists struct{} diff --git a/internal/discriminator/taglists_test.go b/internal/discriminator/taglists_test.go index b64c4c4..800773e 100644 --- a/internal/discriminator/taglists_test.go +++ b/internal/discriminator/taglists_test.go @@ -8,9 +8,9 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names. +// tagLists is SWW_WAF_TAG_LISTS, and vpnList and torList lists it names. const ( - tagLists = "SWW_TAG_LISTS" + tagLists = "SWW_WAF_TAG_LISTS" vpnList = "https://lists.example/vpn.txt" torList = "https://lists.example/torexit.txt" ) diff --git a/internal/discriminator/trappaths.go b/internal/discriminator/trappaths.go index adb80e6..b157b97 100644 --- a/internal/discriminator/trappaths.go +++ b/internal/discriminator/trappaths.go @@ -1,6 +1,6 @@ package discriminator -// The heuristic trap_paths. It reads SWW_TRAP_PATHS. +// The heuristic trap_paths. It reads SWW_WAF_TRAP_PATHS. import ( "context" @@ -11,7 +11,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/rules" ) -// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a +// trapPaths refuses a request for a path in SWW_WAF_TRAP_PATHS: its path as a // path rule sees it, without the query, as sent or once percent-decoded, is one // of them. Such a request is a clear sign of attack, as a ban rule's match is: // it bans the client's netblock, or in observe mode raises the alert for the diff --git a/internal/discriminator/trappaths_test.go b/internal/discriminator/trappaths_test.go index b2af734..bdd0480 100644 --- a/internal/discriminator/trappaths_test.go +++ b/internal/discriminator/trappaths_test.go @@ -14,14 +14,14 @@ import ( // trapPaths is the setting's name, and trapPathList what the tests set it // to. const ( - trapPaths = "SWW_TRAP_PATHS" + trapPaths = "SWW_WAF_TRAP_PATHS" trapPathList = "/wp-login.php,/xmlrpc.php" ) func TestTrapPathBansAsABanRuleDoes(t *testing.T) { t.Parallel() - const allowed = "192.0.2.60" // in SWW_ALLOW_NETS + const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS // A block rule for the same path: the trap path comes first. s, clk, server := startWithClock(t, "", map[string]string{ @@ -38,7 +38,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) { s.request(otherClient, path, http.StatusOK, requestlog.ActionForward) } - // A client in SWW_ALLOW_NETS is not checked. + // A client in SWW_WAF_ALLOW_NETS is not checked. s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) // The query is not part of the path. @@ -114,8 +114,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ - trapPaths: trapPathList, - "SWW_RULES_ENABLED": "false", + trapPaths: trapPathList, + "SWW_WAF_RULES_ENABLED": "false", }) s.request(client, "/xmlrpc.php", http.StatusForbidden, diff --git a/internal/lookup/file.go b/internal/lookup/file.go index 5cf7c72..c385beb 100644 --- a/internal/lookup/file.go +++ b/internal/lookup/file.go @@ -24,7 +24,7 @@ const quietTime = 2 * time.Second // FileParams are what OpenFile needs. type FileParams struct { // Path is the lookup database, the IPinfo Lite file in its .mmdb form - // (SWW_LOOKUP_DB_PATH). + // (SWW_WAF_LOOKUP_DB_PATH). Path string // Now tells the time, normally time.Now. Now func() time.Time @@ -231,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) { func read(path string) (*maxminddb.Reader, error) { data, err := os.ReadFile(path) //nolint:gosec // the file the admin names if err != nil { - return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err) + return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH cannot be read: %w", err) } reader, err := maxminddb.OpenBytes(data) if err != nil { - return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) + return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) } return reader, nil diff --git a/internal/lookup/file_internal_test.go b/internal/lookup/file_internal_test.go index 02f7b0d..8ca0bf1 100644 --- a/internal/lookup/file_internal_test.go +++ b/internal/lookup/file_internal_test.go @@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) { writeFile(t, notDatabase, "not a lookup database\n") for path, want := range map[string]string{ - missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing + + missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing + ": no such file or directory", - notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase + + notDatabase: "SWW_WAF_LOOKUP_DB_PATH " + notDatabase + " is not a .mmdb file: error opening database: invalid MaxMind DB file", } { _, err := OpenFile(FileParams{ @@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) Reason: "the lookup database cannot be read, and the one read before stays in use", Detail: map[string]any{ "file": path, - "error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + + "error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error opening database: invalid MaxMind DB file", }, }) diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index fdcfd1f..41475db 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -1,6 +1,6 @@ // Package lookup looks up each client's AS number and country, through // the GeoJS web service or in the lookup database, the IPinfo Lite file -// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at +// SWW_WAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at // most 100,000 clients and for 7 days each, and are written to // lookups.json and read from it by the state package. package lookup @@ -66,7 +66,7 @@ type Params struct { URL string // Timeout is how long a request waits for its client's first answer, // and how long a request to GeoJS may take before it is abandoned - // (SWW_LOOKUP_TIMEOUT). + // (SWW_WAF_LOOKUP_TIMEOUT). Timeout time.Duration // Wait is true when a setting needs each request's answer before the // request goes on. Otherwise no request waits for one. diff --git a/internal/metrics/busiest.go b/internal/metrics/busiest.go index 0cf9215..fe41eac 100644 --- a/internal/metrics/busiest.go +++ b/internal/metrics/busiest.go @@ -42,7 +42,7 @@ type busiest struct { func newCountries(topN int) *busiest { countries := newBusiest(topN, "country", "the client's country") countries.refused = counterVec("sww_country_list_refusals_total", - "Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", + "Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", []string{"country"}) return countries diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f5033c7..a16d958 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -62,8 +62,8 @@ type Metrics struct { // New returns the metrics, with the Go runtime's and the process's own. // topN is how many countries and how many AS numbers get series of their -// own (SWW_METRICS_TOP_N). Every metric carries instanceName -// (SWW_INSTANCE_NAME) as its label instance. +// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName +// (SWW_WAF_INSTANCE_NAME) as its label instance. // //nolint:funlen // a few lines for each metric, a list that grows with them func New(topN int, instanceName string) *Metrics { @@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics { offences: counterVec("sww_offences_total", "Offences, by kind.", []string{"kind"}), coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total", - "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+ + "Requests that matched a rule of the Core Rule Set, by SWW_WAF_CORE_RULE_SET_MODE "+ "and the rule's id.", []string{"mode", "rule_id"}), countries: newCountries(topN), asns: newASNs(topN), @@ -214,13 +214,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) { } // AddRemoteLog adds the metrics of sending the log lines to -// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the +// SWW_WAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the // lines sent, those dropped, and those waiting in the buffer. func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { m.registry.MustRegister( prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "sww_remote_log_lines_sent_total", - Help: "Log lines sent to SWW_LOG_REMOTE_URL.", + Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.", }, func() float64 { return float64(remote.Sent()) }), @@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL // AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb, // read from abuseIPDB as the metrics are asked for: the checks made, those // that failed, and how many checks the day's budget has left. It is -// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set. +// called once, after AddReputation, while SWW_WAF_ABUSEIPDB_KEY is set. func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource} @@ -322,7 +322,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Name: "sww_reputation_daily_budget_remaining", - Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.", + Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.", ConstLabels: source, }, func() float64 { return float64(abuseIPDB.BudgetLeft()) @@ -363,8 +363,8 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) { }), prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "sww_alerts_suppressed_total", - Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " + - "alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.", + Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " + + "alerts past SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary.", ConstLabels: destination, }, func() float64 { return float64(queue.Suppressed()) @@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) { } // CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id, -// with SWW_WAF_MODE at mode. +// with SWW_WAF_CORE_RULE_SET_MODE at mode. func (m *Metrics) CoreRuleSetMatched(mode string, id int) { m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() } diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index f39eecb..e08fa94 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -2,7 +2,7 @@ // and bytes counted over a minute, an hour and a day, as the "Counting // method" section of SPEC.md describes, which tell when a request takes // the client over a rate limit or a byte limit, and each client's history -// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are +// since it was first seen. At most SWW_WAF_MAX_TRACKED_CLIENTS clients are // kept, in memory, and written to clients.json and read from it by the // state package. package ratelimit @@ -157,7 +157,7 @@ type Request struct { } // New returns a Limiter for limits, with no client counted yet, whose -// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past +// table holds at most maxClients clients (SWW_WAF_MAX_TRACKED_CLIENTS). Past // it, the least recently seen client is dropped, with its history, and // starts afresh if it comes back. func New(limits Limits, maxClients int) *Limiter { diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go index 04424d6..625abc5 100644 --- a/internal/ratelimit/ratelimit_test.go +++ b/internal/ratelimit/ratelimit_test.go @@ -13,7 +13,7 @@ import ( const limit = 3 // tableSize is the most clients the tests' tables hold, the default of -// SWW_MAX_TRACKED_CLIENTS. +// SWW_WAF_MAX_TRACKED_CLIENTS. const tableSize = 20000 // whole is the percentage of each limit a client gets when nothing lowers diff --git a/internal/remotelog/remotelog.go b/internal/remotelog/remotelog.go index 0a2eb47..dcd7f42 100644 --- a/internal/remotelog/remotelog.go +++ b/internal/remotelog/remotelog.go @@ -1,5 +1,5 @@ // Package remotelog sends the lines sww writes on stdout to the -// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section +// remote log endpoint, SWW_WAF_LOG_REMOTE_URL, as the "Request log" section // of SPEC.md describes: each line as the message of an RFC 5424 syslog // record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow // or unreachable endpoint never holds up a request or stdout. @@ -24,7 +24,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// The forms of SWW_LOG_REMOTE_URL, by its scheme. +// The forms of SWW_WAF_LOG_REMOTE_URL, by its scheme. const ( SchemeUDP = "syslog+udp" SchemeTCP = "syslog+tcp" @@ -56,18 +56,18 @@ const ( // Params are what New needs. type Params struct { - // URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or + // URL is the endpoint (SWW_WAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or // SchemeTLS, a host and a port. URL *url.URL // RootCAs are the certificates a SchemeTLS endpoint's certificate - // must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's. + // must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's. RootCAs *x509.CertPool // Buffer is the most lines held while they wait to be sent - // (SWW_LOG_REMOTE_BUFFER). + // (SWW_WAF_LOG_REMOTE_BUFFER). Buffer int // Facility is the number of the records' syslog facility - // (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME - // (SWW_LOG_REMOTE_APP_NAME). + // (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME + // (SWW_WAF_LOG_REMOTE_APP_NAME). Facility int AppName string } @@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn { } } - processLog.Warn("sending to SWW_LOG_REMOTE_URL failed", + processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed", "error", err.Error(), "connecting_again_in", delay.String()) select { diff --git a/internal/remotelog/remotelog_test.go b/internal/remotelog/remotelog_test.go index 6d9a9b0..57fdbd3 100644 --- a/internal/remotelog/remotelog_test.go +++ b/internal/remotelog/remotelog_test.go @@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) { var fields map[string]any err := json.Unmarshal([]byte(line), &fields) - if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" { + if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" { t.Fatalf("logged %q", line) } diff --git a/internal/reputation/abuseipdb.go b/internal/reputation/abuseipdb.go index b7de34b..fc0be8b 100644 --- a/internal/reputation/abuseipdb.go +++ b/internal/reputation/abuseipdb.go @@ -58,17 +58,17 @@ type Checks struct { // AbuseIPDBParams are what NewAbuseIPDB needs. type AbuseIPDBParams struct { // URL is where clients are checked, normally AbuseIPDBURL, with Key, - // the account's key (SWW_ABUSEIPDB_KEY). + // the account's key (SWW_WAF_ABUSEIPDB_KEY). URL string Key string - // MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE), + // MinScore is the least score that is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), // and DailyBudget the most checks made in a day, in UTC - // (SWW_ABUSEIPDB_DAILY_BUDGET). + // (SWW_WAF_ABUSEIPDB_DAILY_BUDGET). MinScore int64 DailyBudget int // CacheTTL is how long a score is used after it was fetched - // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take - // (SWW_REPUTATION_TIMEOUT). + // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take + // (SWW_WAF_REPUTATION_TIMEOUT). CacheTTL time.Duration Timeout time.Duration // Now tells the time, normally time.Now in UTC. diff --git a/internal/reputation/dnsbl.go b/internal/reputation/dnsbl.go index 83cd2a4..6591a92 100644 --- a/internal/reputation/dnsbl.go +++ b/internal/reputation/dnsbl.go @@ -53,15 +53,15 @@ type Verdict struct { // DNSBLParams are what NewDNSBL needs. type DNSBLParams struct { // Zones are the DNSBL zones clients are asked about in - // (SWW_DNSBL_ZONES). + // (SWW_WAF_DNSBL_ZONES). Zones []string // Resolver is the resolver they are asked through - // (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the + // (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the // host's, as /etc/resolv.conf names it. Resolver netip.AddrPort // CacheTTL is how long a verdict is used after it was fetched - // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take - // (SWW_REPUTATION_TIMEOUT). + // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take + // (SWW_WAF_REPUTATION_TIMEOUT). CacheTTL time.Duration Timeout time.Duration // Now tells the time, normally time.Now in UTC. @@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL { } } -// Zones returns the zones, in the order SWW_DNSBL_ZONES names them. +// Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them. func (d *DNSBL) Zones() []string { return slices.Clone(d.params.Zones) } // ListedBy returns the zones whose verdict on addr, a client's address, -// lists it, in the order SWW_DNSBL_ZONES names them, each with its key +// lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key // masked, as config.MaskZoneKey masks it, since they go to the request // log, the alerts and the metrics. A verdict is used until CacheTTL has // passed since it was fetched. Each zone without one is asked about addr @@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict { } // Load keeps verdicts, read from reputation.json, in place of those it -// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and, +// keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and, // past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or // more is neither used nor written, as for any verdict. func (d *DNSBL) Load(verdicts []Verdict) { diff --git a/internal/reputation/dnsbl_test.go b/internal/reputation/dnsbl_test.go index bdf4f98..310f21a 100644 --- a/internal/reputation/dnsbl_test.go +++ b/internal/reputation/dnsbl_test.go @@ -40,8 +40,8 @@ const ( // zone and otherZone are the DNSBL zones the tests name. zone = "dnsbl.example" otherZone = "other.example" - // cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their - // SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf + // cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their + // SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf // can have Go's resolver wait for one server, so that it is the // DNSBL's own timeout that ends a query, whatever that file says. cacheTTL = 24 * time.Hour @@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) { dnsbl := reputation.NewDNSBL(p) // The last verdict still in use, one fetched a TTL ago, and one of a - // zone SWW_DNSBL_ZONES does not name. + // zone SWW_WAF_DNSBL_ZONES does not name. inUse := reputation.Verdict{ Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: now.Add(-cacheTTL + time.Nanosecond), diff --git a/internal/reputation/reputation.go b/internal/reputation/reputation.go index 64db688..0cbfbd5 100644 --- a/internal/reputation/reputation.go +++ b/internal/reputation/reputation.go @@ -1,11 +1,11 @@ // Package reputation fetches the lists the settings name by URL: the -// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines -// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and -// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It +// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines +// SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and +// the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It // keeps the last good copy of // each, whole, comment lines included, which is used while a fetch fails, // and when each was last tried. It also asks the DNSBL zones of -// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks +// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks // clients with AbuseIPDB, and keeps their scores and the checks spent // today. The state package writes all of these to reputation.json and // reads them from it, so that a restart keeps them too. @@ -67,20 +67,20 @@ type List struct { // Params are what New needs. type Params struct { - // BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and + // BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and // ASNLimitPercentURL the file of AS:percent lines - // (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset. + // (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset. BlocklistURLs []string ASNLimitPercentURL string - // TagLists are the address lists that add a tag (SWW_TAG_LISTS). + // TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS). TagLists []config.TagList // CrowdSecDecisionsURL is the CrowdSec decision list, "" while - // SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey - // (SWW_CROWDSEC_LAPI_KEY). + // SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey + // (SWW_WAF_CROWDSEC_LAPI_KEY). CrowdSecDecisionsURL string CrowdSecKey string // Refresh is how long after a list was last fetched or tried it is - // fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision + // fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision // list, which is fetched again crowdSecRefresh after. Refresh time.Duration // Now tells the time, normally time.Now in UTC. @@ -155,7 +155,7 @@ func New(params Params) *Lists { } // URLs returns the URL of every list: the blocklists' in the order -// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the +// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the // tag lists', then the CrowdSec decision list's. func (l *Lists) URLs() []string { urls := slices.Clone(l.params.BlocklistURLs) @@ -175,7 +175,7 @@ func (l *Lists) URLs() []string { } // ListedBy returns the URLs of the blocklists whose copy lists addr, in -// the order SWW_BLOCKLIST_URLS names them. +// the order SWW_WAF_BLOCKLIST_URLS names them. func (l *Lists) ListedBy(addr netip.Addr) []string { l.mu.Lock() defer l.mu.Unlock() diff --git a/internal/reputation/reputation_test.go b/internal/reputation/reputation_test.go index 3a9011d..0a6a08d 100644 --- a/internal/reputation/reputation_test.go +++ b/internal/reputation/reputation_test.go @@ -34,8 +34,8 @@ const ( dropURL = "https://lists.example/drop.txt" torURL = "https://lists.example/tor.txt" asnURL = "https://lists.example/asn.txt" - // refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their - // SWW_ALERT_COOLDOWN, longer than it. + // refresh is the tests' SWW_WAF_BLOCKLIST_REFRESH, and cooldown their + // SWW_WAF_ALERT_COOLDOWN, longer than it. refresh = 24 * time.Hour cooldown = 48 * time.Hour // drop is a blocklist as the Spamhaus DROP list is written, with an @@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) { }} lists := start(t, servers, params(torURL, dropURL)) - // In the order SWW_BLOCKLIST_URLS names them. + // In the order SWW_WAF_BLOCKLIST_URLS names them. wantListedBy(t, lists, "203.0.113.9", torURL, dropURL) wantListedBy(t, lists, "203.0.113.8", dropURL) }) diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index b7e681f..f7be5ec 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -43,17 +43,17 @@ const ( ActionBlockedByBlockRule = "blocked_by_block_rule" // ActionBlockedByCoreRuleSet is a request refused because the Core Rule - // Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD. + // Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set" // ActionClientDenied is a request refused because its client is in - // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, + // SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny, // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while - // SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY. + // SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY. ActionClientDenied = "client_denied" // ActionCountryDenied is a request refused because its client's country - // is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. + // is not in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES. ActionCountryDenied = "country_denied" // ActionOwnEndpoint is a request discriminator answered at one of its own // endpoints, or a question about a path under /_sww/. @@ -125,7 +125,7 @@ type Line struct { ContentType string `json:"content_type,omitempty"` // ContentLength is the length of its body the request announced. ContentLength int64 `json:"content_length,omitempty"` - // RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that + // RequestHeaders are the headers SWW_WAF_LOG_REQUEST_HEADERS names that // the request carried, by name in lower case. RequestHeaders map[string]string `json:"request_headers,omitempty"` HasAuthorization bool `json:"has_authorization,omitempty"` @@ -188,7 +188,7 @@ type Line struct { Reputation []string `json:"reputation,omitempty"` // Tags are the request's tags, sorted. Tags []string `json:"tags,omitempty"` - // RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the + // RouteWatchTagsMissing, while SWW_WAF_ROUTEWATCH_URL is set, says why the // request got no tags from routewatch: no_answer_yet, request_failed or // no_route. RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"` @@ -232,7 +232,7 @@ type RProxyLine struct { DurationRequestMS float64 `json:"duration_request_ms"` DurationResponseMS float64 `json:"duration_response_ms"` // Disposition is the valid disposition discriminator answered or, while - // DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" + // DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is "" // when neither gave one. Disposition string `json:"disposition,omitempty"` DefaultDisposition bool `json:"default_disposition,omitempty"` @@ -284,8 +284,8 @@ func Milliseconds(d time.Duration) float64 { // NewProcessLogger returns the logger for the process's own messages: // JSON lines on w, marked "type":"process", with the time in the same form -// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance. -// It writes only the messages at level, SWW_LOG_LEVEL, or more severe; +// as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance. +// It writes only the messages at level, SWW_WAF_LOG_LEVEL, or more severe; // the request lines Write writes are never held back. func NewProcessLogger( w io.Writer, diff --git a/internal/routewatch/routewatch.go b/internal/routewatch/routewatch.go index ed4e701..fb7feef 100644 --- a/internal/routewatch/routewatch.go +++ b/internal/routewatch/routewatch.go @@ -67,7 +67,7 @@ type Route struct { // Params are what New needs. type Params struct { - // URL is where routewatch is (SWW_ROUTEWATCH_URL). + // URL is where routewatch is (SWW_WAF_ROUTEWATCH_URL). URL string // Now tells the time, normally time.Now in UTC. Now func() time.Time diff --git a/internal/rproxy/bodies.go b/internal/rproxy/bodies.go index 4bd20d1..e888ce4 100644 --- a/internal/rproxy/bodies.go +++ b/internal/rproxy/bodies.go @@ -10,16 +10,16 @@ import ( ) // errResponseTooLarge ends an app's response body that is longer than -// SWRP_MAX_RESPONSE_SIZE. +// SWW_RPROXY_MAX_RESPONSE_SIZE. var errResponseTooLarge = errors.New( - "the response body is over SWRP_MAX_RESPONSE_SIZE") + "the response body is over SWW_RPROXY_MAX_RESPONSE_SIZE") // requestBody is the visitor's request body. rproxy first reads up to the first // 64 KiB of it into held, and then, on its way to the app, the transport reads // it on a goroutine of its own. type requestBody struct { // body is the visitor's body, ending in an *http.MaxBytesError past - // SWRP_MAX_REQUEST_SIZE. + // SWW_RPROXY_MAX_REQUEST_SIZE. body io.ReadCloser rq *request // held is the part read before the request goes to the app, which Read gives @@ -68,7 +68,7 @@ func (b *requestBody) Close() error { // responseBody is the app's response body on its way to the visitor. type responseBody struct { // body is the app's body, ending in an *http.MaxBytesError past - // SWRP_MAX_RESPONSE_SIZE. + // SWW_RPROXY_MAX_RESPONSE_SIZE. body io.ReadCloser rq *request } diff --git a/internal/rproxy/discriminator.go b/internal/rproxy/discriminator.go index d5a5832..0a443fa 100644 --- a/internal/rproxy/discriminator.go +++ b/internal/rproxy/discriminator.go @@ -65,9 +65,9 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { rq.answer(http.StatusBadGateway) default: // discriminator cannot be connected to, does not answer at all, or does not answer - // within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past - // SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets - // SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503 + // within SWW_RPROXY_TIMEOUT_MS. A request that keeps discriminator busy past + // SWW_RPROXY_TIMEOUT_MS, like any request while discriminator is overloaded, gets + // SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503 // whenever discriminator is slow. Making such requests hard to send is // https://git.eeqj.de/sneak/smallwebwaf/issues/182. rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error()) @@ -84,7 +84,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { } // ask sends discriminator the question, and returns its answer, which must have come -// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes +// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes // away. func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) { ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) @@ -228,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) } // postReport posts report to discriminator, and waits for its answer for up to -// SWRP_TIMEOUT_MS. +// SWW_RPROXY_TIMEOUT_MS. func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error { body, err := json.Marshal(report) if err != nil { diff --git a/internal/rproxy/limits_test.go b/internal/rproxy/limits_test.go index 57224e7..0eb20f0 100644 --- a/internal/rproxy/limits_test.go +++ b/internal/rproxy/limits_test.go @@ -16,6 +16,7 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rproxy" @@ -33,13 +34,9 @@ func announcedLength(size int) string { func TestServerHasTheFixedLimits(t *testing.T) { t.Parallel() - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - if name == "SWRP_WAF_BACKEND" { - return nowhere, true - } - - return "", false - }) + cfg, err := config.RProxyFromEnvironment(configtest.Viper(map[string]string{ + "SWW_RPROXY_WAF_BACKEND": nowhere, + })) if err != nil { t.Fatalf("settings: %v", err) } diff --git a/internal/rproxy/request.go b/internal/rproxy/request.go index 9c7b224..e178b9a 100644 --- a/internal/rproxy/request.go +++ b/internal/rproxy/request.go @@ -50,10 +50,10 @@ type request struct { start time.Time // disposition is what is done with the request: discriminator's valid disposition, - // or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one. + // or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one. disposition string // answered is true when discriminator gave disposition, which makes a report due, - // and byDefault when SWRP_DEFAULT_DISPOSITION did. + // and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did. answered bool byDefault bool // asn and country are discriminator's X-Client-ASN and X-Client-Country, for the @@ -118,10 +118,10 @@ func (rq *request) answerHealth() { } // readFirstPartOfBody reads up to the first 64 KiB of the body into memory, -// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A +// within SWW_RPROXY_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A // visitor too slow to send it is answered 408. For a malformed chunk, or a // visitor that went away or stopped its sending side, the connection is closed -// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part +// without an answer. A body over SWW_RPROXY_MAX_REQUEST_SIZE goes on with the part // read, so that discriminator is asked first; passToApp then refuses it. func (rq *request) readFirstPartOfBody() bool { if rq.body == nil { @@ -190,7 +190,7 @@ func (rq *request) forward(ctx context.Context) { } // rewrite makes the request the app receives: the visitor's request, unchanged, -// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set, +// sent to SWW_RPROXY_UPSTREAM_URL, with the forwarded headers and the request's id set, // without any X-Client-ASN or X-Client-Country the visitor sent, and with // discriminator's. func (rq *request) rewrite(pr *httputil.ProxyRequest) { @@ -325,7 +325,7 @@ func (rq *request) send(status int, body []byte) { // prepareOwnAnswer readies the connection for an answer rproxy gives itself: the // reading of a body that has not arrived whole ends at deadline, and the answer -// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app +// has SWW_RPROXY_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app // used that time up first. func (rq *request) prepareOwnAnswer(deadline time.Time) { rq.stopReadingBody(deadline) @@ -419,7 +419,7 @@ func (rq *request) bytesEachWay() (int64, int64) { } // requestDeadline is when the visitor must have sent its whole request, or zero -// when SWRP_MAX_REQUEST_TIME_MS is off. +// when SWW_RPROXY_MAX_REQUEST_TIME_MS is off. func (rq *request) requestDeadline() time.Time { limit := rq.h.config.MaxRequestTime if limit == 0 { @@ -440,7 +440,7 @@ func (rq *request) stopReadingBody(deadline time.Time) { _ = rq.rc.SetReadDeadline(deadline) } -// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the +// startRequestTimer starts SWW_RPROXY_MAX_REQUEST_TIME_MS, which runs from the // request's headers being read until the app has been sent the whole request. func (rq *request) startRequestTimer() { deadline := rq.requestDeadline() @@ -454,7 +454,7 @@ func (rq *request) startRequestTimer() { rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut) } -// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the +// requestTimedOut is called when SWW_RPROXY_MAX_REQUEST_TIME_MS runs out while the // request is still on its way to the app. The answer names the side rproxy was // waiting on at that moment: 408 when it was waiting for the visitor to send more // of its body, 504 when it was waiting for the app to be reached or to take what @@ -500,7 +500,7 @@ func (rq *request) gotFirstResponseByte() { } // wroteRequest is called once the app has been sent the whole request: -// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts. +// SWW_RPROXY_MAX_REQUEST_TIME_MS ends and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts. func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) { if info.Err != nil { return // the transport gives up, or tries again @@ -524,7 +524,7 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) { } } -// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the +// responseTimedOut is called when SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out before the // app has sent its whole answer. func (rq *request) responseTimedOut() { rq.mu.Lock() @@ -539,7 +539,7 @@ func (rq *request) responseTimedOut() { // answer before the visitor has sent its whole body, as with a 404 it sends // without reading it. The time limits then run on: until the transport's read of // the body returns, Go's server cannot send the answer, and -// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops +// SWW_RPROXY_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops // sending. func (rq *request) responseReceived() { if rq.body != nil && !rq.body.received.Load() { @@ -549,7 +549,7 @@ func (rq *request) responseReceived() { rq.stopTimers() } -// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the +// setResponseDeadline sets SWW_RPROXY_MAX_RESPONSE_TIME_MS on the connection to the // visitor: the app's answer must reach the visitor within it of the end of the // request. func (rq *request) setResponseDeadline() { diff --git a/internal/rproxy/rproxy.go b/internal/rproxy/rproxy.go index 0a4c9f0..0bf6792 100644 --- a/internal/rproxy/rproxy.go +++ b/internal/rproxy/rproxy.go @@ -1,11 +1,12 @@ // Package rproxy is the reverse proxy in front of one app that asks discriminator // about each request. It passes a request discriminator allows to the app and the // app's answer back, within the size and time limits. Any other answer from -// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION +// discriminator goes to the visitor as discriminator gave it. +// SWW_RPROXY_DEFAULT_DISPOSITION // decides only when discriminator cannot be connected to, does not answer at all, or -// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a +// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a // method it does not pass on, or one it cannot put to discriminator as the visitor -// sent it. It keeps nothing between requests and never touches disk. "rproxy" in +// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in // SPEC.md says what it does. package rproxy @@ -71,7 +72,7 @@ type Server struct { // New returns the server `sww rproxy` runs. Go's server itself refuses // a request line and headers over requestHeaderMaxBytes, with 431, closes a -// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while +// connection idle for idleTimeout, and applies SWW_RPROXY_MAX_REQUEST_TIME_MS while // the headers arrive. The handler applies the time and size limits from then // on. func New(params Params) *Server { @@ -103,7 +104,7 @@ func New(params Params) *Server { } // WaitForReports returns once the reports being sent have gone or failed, -// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of +// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of // a WebSocket that closes while rproxy stops, for one. func (s *Server) WaitForReports() { s.h.reportsMu.Lock() @@ -212,8 +213,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // passToApp passes the request on to the app, unless a limit or its path keeps // it from the app. discriminator answers a path under ownEndpointPrefix itself, so -// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the -// request's own context. +// such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it. +// ctx is the request's own context. func (rq *request) passToApp(ctx context.Context) { maxBytes := rq.h.config.MaxRequestSize diff --git a/internal/rproxy/rproxy_test.go b/internal/rproxy/rproxy_test.go index ec6d08e..8af33e7 100644 --- a/internal/rproxy/rproxy_test.go +++ b/internal/rproxy/rproxy_test.go @@ -19,6 +19,7 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rproxy" @@ -43,13 +44,13 @@ const ( // The settings the tests set. const ( - timeoutSetting = "SWRP_TIMEOUT_MS" - defaultDisposition = "SWRP_DEFAULT_DISPOSITION" - trustedProxies = "SWRP_TRUSTED_PROXIES" - maxRequestSize = "SWRP_MAX_REQUEST_SIZE" - maxResponseSize = "SWRP_MAX_RESPONSE_SIZE" - maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS" - maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS" + timeoutSetting = "SWW_RPROXY_TIMEOUT_MS" + defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION" + trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES" + maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE" + maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE" + maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS" + maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS" ) // The headers the tests set and look at. @@ -354,15 +355,11 @@ func startServerLogging( t.Helper() settings := map[string]string{ - "SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL, + "SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL, } maps.Copy(settings, env) - cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { - value, ok := settings[name] - - return value, ok - }) + cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings)) if err != nil { t.Fatalf("settings %v: %v", settings, err) } diff --git a/internal/rules/rules.go b/internal/rules/rules.go index 3e4014c..83eee7d 100644 --- a/internal/rules/rules.go +++ b/internal/rules/rules.go @@ -1,5 +1,5 @@ // Package rules reads the rule files: the plain text files in -// SWW_RULES_DIR, one rule to a line, that each request is checked +// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked // against, as the "Rule files" section of SPEC.md describes. They are read // at start, and again once the directory has had no change for a short // time after one is edited, added or removed. @@ -40,7 +40,7 @@ const ( // extension ends the name of every rule file. const extension = ".rules" -// quietTime is how long SWW_RULES_DIR must go without a change before +// quietTime is how long SWW_WAF_RULES_DIR must go without a change before // the rule files are read again, so that a file still being written, such // as one saved in place, appended to or copied in with scp, is read only // once whole. @@ -100,9 +100,9 @@ type Rule struct { // Params are what Load needs. type Params struct { - // Dir is the directory of the rule files (SWW_RULES_DIR). + // Dir is the directory of the rule files (SWW_WAF_RULES_DIR). Dir string - // Enabled is SWW_RULES_ENABLED: while it is false, no file is read + // Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read // and no rule loaded. Enabled bool // ProcessLog receives how many rules were read, and the error in a @@ -271,7 +271,7 @@ func (f *Files) logRead(count int) { func read(dir string) ([]Rule, string, error) { entries, err := os.ReadDir(dir) if err != nil { - return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err) + return nil, dir, fmt.Errorf("SWW_WAF_RULES_DIR cannot be read: %w", err) } var rules []Rule @@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) { // readFile appends the rules of the rule file at path to rules. places // are where each id read so far is, and gain those of the file. func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) { - data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR + data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_WAF_RULES_DIR if err != nil { return nil, err } diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go index 732da77..2fc39a7 100644 --- a/internal/rules/rules_test.go +++ b/internal/rules/rules_test.go @@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) { dir := filepath.Join(t.TempDir(), "rules.d") - wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+ + wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+ ": no such file or directory") } @@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) { func TestRuleFilesOffReadNothing(t *testing.T) { t.Parallel() - // SWW_RULES_DIR does not exist, which would stop the start. + // SWW_WAF_RULES_DIR does not exist, which would stop the start. params, _ := newParams(filepath.Join(t.TempDir(), "rules.d")) params.Enabled = false diff --git a/internal/state/state.go b/internal/state/state.go index 6058859..18b3cfe 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,5 +1,5 @@ // Package state keeps sww's state in JSON files in -// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires +// SWW_WAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires // and docs/state-files.md lists them: // bans.json holds the bans, clients.json each client's counters and // history, lookups.json GeoJS's answers, reputation.json the last try and @@ -72,11 +72,11 @@ var ( // Params are what Load needs. type Params struct { - // Dir is the directory of the state files (SWW_STATE_DIR). + // Dir is the directory of the state files (SWW_WAF_STATE_DIR). Dir string // WriteDelay is how long after a ban is made bans.json is written - // (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file - // is (SWW_STATE_COUNTER_INTERVAL). + // (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file + // is (SWW_WAF_STATE_COUNTER_INTERVAL). WriteDelay time.Duration CounterInterval time.Duration // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies @@ -185,7 +185,7 @@ type stateFile interface { func Load(params Params) (*Files, error) { err := checkWritable(params.Dir) if err != nil { - return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err) + return nil, fmt.Errorf("SWW_WAF_STATE_DIR cannot be written: %w", err) } f := &Files{params: params, sums: map[string][sha256.Size]byte{}} @@ -362,7 +362,7 @@ func (f *Files) read(name string) (int, error) { func (f *Files) readChanged(name string) ([]byte, bool, error) { path := filepath.Join(f.params.Dir, name) - data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR + data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_WAF_STATE_DIR if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } @@ -1035,7 +1035,7 @@ func write(dir, name string, data []byte) error { // syncDirectory syncs dir to the disk, so that a rename in it lasts. func syncDirectory(dir string) error { - directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself + directory, err := os.Open(dir) //nolint:gosec // SWW_WAF_STATE_DIR itself if err != nil { return err } @@ -1045,7 +1045,7 @@ func syncDirectory(dir string) error { // writeSynced writes data to the file at path, and syncs it to the disk. func writeSynced(path string, data []byte) error { - //nolint:gosec // a state file's temporary file, in SWW_STATE_DIR + //nolint:gosec // a state file's temporary file, in SWW_WAF_STATE_DIR file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { return err diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 2fb9020..ffbf312 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -931,7 +931,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) { filepath.Join(t.TempDir(), "missing"), notADirectory, } { - const want = "SWW_STATE_DIR cannot be written: " + const want = "SWW_WAF_STATE_DIR cannot be written: " _, err := state.Load(newParams(dir)) if err == nil || !strings.HasPrefix(err.Error(), want) { diff --git a/internal/sww/healthcheck.go b/internal/sww/healthcheck.go index e56e8ee..c42104e 100644 --- a/internal/sww/healthcheck.go +++ b/internal/sww/healthcheck.go @@ -10,6 +10,8 @@ import ( "net/url" "time" + "github.com/spf13/viper" + "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/discriminator" ) @@ -21,17 +23,17 @@ var errHealthEndpoint = errors.New("the health endpoint answered") // HealthCheck is the container's health check. In discriminator's container, where // SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health -// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container, +// endpoint on 127.0.0.1, at the port in SWW_WAF_LISTEN_ADDR. In rproxy's container, // where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy -// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and -// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise, +// answers its health endpoint on 127.0.0.1, at the port in SWW_RPROXY_LISTEN_ADDR, and +// the app accepts connections at the address in SWW_RPROXY_UPSTREAM_URL. Otherwise, // any other value of SWW_COMMAND included, it writes why to stderr and // returns 1. It reads no other setting, nor a file that another names, so neither // can fail it. // args are the arguments after `healthcheck`; it takes none, and given // one it names it on stderr and returns 1 without checking anything. func HealthCheck( - ctx context.Context, args []string, lookupEnv func(string) (string, bool), + ctx context.Context, args []string, v *viper.Viper, stderr io.Writer, ) int { if len(args) > 0 { @@ -44,15 +46,15 @@ func HealthCheck( ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) defer cancel() - command, err := config.Command(lookupEnv) + command, err := config.Command(v) switch { case err != nil: err = fmt.Errorf("invalid setting: %w", err) case command == "discriminator": - err = checkDiscriminator(ctx, lookupEnv) + err = checkDiscriminator(ctx, v) default: - err = checkRProxyAndApp(ctx, lookupEnv) + err = checkRProxyAndApp(ctx, v) } if err != nil { @@ -65,10 +67,8 @@ func HealthCheck( } // checkDiscriminator asks discriminator's health endpoint. -func checkDiscriminator( - ctx context.Context, lookupEnv func(string) (string, bool), -) error { - listenAddr, err := config.DiscriminatorListenAddr(lookupEnv) +func checkDiscriminator(ctx context.Context, v *viper.Viper) error { + listenAddr, err := config.DiscriminatorListenAddr(v) if err != nil { return fmt.Errorf("invalid setting: %w", err) } @@ -82,10 +82,8 @@ func checkDiscriminator( } // checkRProxyAndApp asks rproxy's health endpoint, and connects to the app. -func checkRProxyAndApp( - ctx context.Context, lookupEnv func(string) (string, bool), -) error { - listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv) +func checkRProxyAndApp(ctx context.Context, v *viper.Viper) error { + listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v) if err != nil { return fmt.Errorf("invalid setting: %w", err) } diff --git a/internal/sww/healthcheck_test.go b/internal/sww/healthcheck_test.go index 50a9bf4..dd4d7b6 100644 --- a/internal/sww/healthcheck_test.go +++ b/internal/sww/healthcheck_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/sww" ) @@ -54,9 +55,9 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) { // or invalid token file, or an invalid value of another setting, does not // fail it. for _, other := range []struct{ name, value string }{ - {"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, - {"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, - {"SWW_MODE", "neither"}, + {"SWW_WAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, + {"SWW_WAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, + {"SWW_WAF_MODE", "neither"}, } { wantHealthCheck(t, map[string]string{ commandSetting: discriminatorCommand, @@ -72,7 +73,7 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) { }, 1, "unhealthy: ask discriminator: ") wantHealthCheck(t, map[string]string{ commandSetting: discriminatorCommand, listenAddr: "8080", - }, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ") + }, 1, "unhealthy: invalid setting: SWW_WAF_LISTEN_ADDR: ") stop() @@ -111,8 +112,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { 0, "") // Another setting does not fail it, even an invalid one, and neither does - // SWRP_WAF_BACKEND being unset. - wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "") + // SWW_RPROXY_WAF_BACKEND being unset. + wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "") // Another test can take a closed server's port at once, so the app's // address here is a multicast address, to which TCP cannot connect. @@ -123,13 +124,14 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { // itself. wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ") wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1, - "unhealthy: invalid setting: SWRP_LISTEN_ADDR: ") + "unhealthy: invalid setting: SWW_RPROXY_LISTEN_ADDR: ") - // No setting of rproxy's has a form given as a file. + // A setting of rproxy's can be given as a file too, here the listen + // address, which the health check reads. wantHealthCheck(t, map[string]string{ rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"), rproxyUpstreamURL: app.URL, - }, 1, "unhealthy: ask rproxy: ") + }, 0, "") } func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) { @@ -145,11 +147,7 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) { var stderr bytes.Buffer - noSettings := func(string) (string, bool) { - return "", false - } - - got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr) + got := sww.HealthCheck(t.Context(), []string{"now"}, configtest.Viper(nil), &stderr) want := "sww healthcheck: unexpected argument \"now\"\n" if got != 1 || stderr.String() != want { @@ -166,11 +164,7 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st var stderr bytes.Buffer - got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) { - value, ok := env[name] - - return value, ok - }, &stderr) + got := sww.HealthCheck(t.Context(), nil, configtest.Viper(env), &stderr) wrote := stderr.String() if got != status || !strings.HasPrefix(wrote, message) || diff --git a/internal/sww/rproxy.go b/internal/sww/rproxy.go index 77ca9e7..8551dbe 100644 --- a/internal/sww/rproxy.go +++ b/internal/sww/rproxy.go @@ -15,7 +15,7 @@ import ( // RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves // requests until ctx is done. Then it gives the requests in progress -// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It +// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It // returns the process's exit status, 1 when rproxy cannot start. Its process // messages name the host's name as instance, and it writes those at info or // more severe: rproxy has no setting for either. @@ -23,7 +23,7 @@ func RunRProxy(ctx context.Context, params Params) int { hostname, _ := os.Hostname() // "" when the host has no name to give processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo) - cfg, err := config.RProxyFromEnvironment(params.LookupEnv) + cfg, err := config.RProxyFromEnvironment(params.Viper) if err != nil { processLog.Error("invalid setting", "error", err.Error()) @@ -38,7 +38,7 @@ func RunRProxy(ctx context.Context, params Params) int { listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) if err != nil { - processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error()) + processLog.Error("cannot listen on SWW_RPROXY_LISTEN_ADDR", "error", err.Error()) return 1 } diff --git a/internal/sww/rproxy_test.go b/internal/sww/rproxy_test.go index 2e7810d..1f64be5 100644 --- a/internal/sww/rproxy_test.go +++ b/internal/sww/rproxy_test.go @@ -19,19 +19,20 @@ import ( "testing" "time" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/sww" ) const ( // wafBackend is the setting that names discriminator to rproxy. - wafBackend = "SWRP_WAF_BACKEND" + wafBackend = "SWW_RPROXY_WAF_BACKEND" // rproxyListenAddr is the setting that says where rproxy listens. - rproxyListenAddr = "SWRP_LISTEN_ADDR" + rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR" // rproxyUpstreamURL is the setting that says where the app is. - rproxyUpstreamURL = "SWRP_UPSTREAM_URL" + rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL" // rproxyTimeout is the setting that says how long rproxy waits for discriminator. - rproxyTimeout = "SWRP_TIMEOUT_MS" + rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS" // bannedProbe is a path the default rule file bans a client for. bannedProbe = "/.env" // otherClient is a client the tests do not ban. @@ -43,12 +44,8 @@ const ( func runRProxy(ctx context.Context, env map[string]string, out *output) int { return sww.RunRProxy(ctx, sww.Params{ Version: testVersion, - LookupEnv: func(name string) (string, bool) { - value, ok := env[name] - - return value, ok - }, - Stdout: out, + Viper: configtest.Viper(env), + Stdout: out, }) } @@ -64,10 +61,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) { go func() { exited <- runRProxy(ctx, map[string]string{ - rproxyListenAddr: localhost + ":0", - "SWRP_UPSTREAM_URL": appURL, - "SWRP_TRUSTED_PROXIES": localhost + "/32", - wafBackend: backend, + rproxyListenAddr: localhost + ":0", + "SWW_RPROXY_UPSTREAM_URL": appURL, + "SWW_RPROXY_TRUSTED_PROXIES": localhost + "/32", + wafBackend: backend, }, out) }() @@ -437,7 +434,7 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output cmd.Env = append(os.Environ(), commandForMain+"=rproxy", - "SWRP_LISTEN_ADDR="+localhost+":0") + "SWW_RPROXY_LISTEN_ADDR="+localhost+":0") cmd.Env = append(cmd.Env, env...) err = cmd.Start() @@ -485,7 +482,7 @@ func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) { addr, _, _ := startRProxyProcess(t, wafBackend+"="+backend.URL, - "SWRP_UPSTREAM_URL="+startApp(t), + "SWW_RPROXY_UPSTREAM_URL="+startApp(t), "SSL_CERT_FILE="+certificates) for name, headers := range map[string]string{ @@ -550,10 +547,10 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) { defer stop() env := map[string]string{ - rproxyListenAddr: localhost + ":0", - "SWRP_UPSTREAM_URL": startApp(t), - rproxyTimeout: "60000", - wafBackend: backend.URL, + rproxyListenAddr: localhost + ":0", + "SWW_RPROXY_UPSTREAM_URL": startApp(t), + rproxyTimeout: "60000", + wafBackend: backend.URL, } out := &output{} exited := make(chan int, 1) @@ -627,7 +624,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) { const ( cutOffAfter = 5 * time.Second - upstreamSetting = "SWRP_UPSTREAM_URL" + upstreamSetting = "SWW_RPROXY_UPSTREAM_URL" ) asked := make(chan struct{}) @@ -749,7 +746,7 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) { t.Errorf("exit status %d, want 1", status) } - out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR") + out.line(t, "msg", "cannot listen on SWW_RPROXY_LISTEN_ADDR") } func TestMainRunsRProxy(t *testing.T) { diff --git a/internal/sww/sww.go b/internal/sww/sww.go index a3a15c1..4f0a983 100644 --- a/internal/sww/sww.go +++ b/internal/sww/sww.go @@ -20,6 +20,8 @@ import ( "syscall" "time" + "github.com/spf13/viper" + "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/coreruleset" @@ -38,7 +40,7 @@ import ( const shutdownTimeout = 5 * time.Second // remoteLogStopTimeout is how long, as discriminator stops, the log lines still -// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has +// waiting are sent to SWW_WAF_LOG_REMOTE_URL before they are given up. stdout has // carried them. const remoteLogStopTimeout = 2 * time.Second @@ -59,8 +61,9 @@ commands: type Params struct { // Version is the version of the binary, set when it is built. Version string - // LookupEnv reads an environment variable, normally os.LookupEnv. - LookupEnv func(string) (string, bool) + // Viper reads the settings, normally the viper config.NewViper returns + // for the process's environment. + Viper *viper.Viper // Stdout receives the request log and the process's own messages. Stdout io.Writer } @@ -79,7 +82,7 @@ func Main(version string) int { syscall.SIGTERM, os.Interrupt) defer stop() - params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout} + params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout} switch args[0] { case "rproxy": @@ -87,7 +90,7 @@ func Main(version string) int { case "discriminator": return RunDiscriminator(ctx, params) case "healthcheck": - return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr) + return HealthCheck(ctx, args[1:], params.Viper, os.Stderr) default: return printUsage(os.Stderr) } @@ -105,18 +108,18 @@ func printUsage(w io.Writer) int { // done. It returns the process's exit status, 1 when discriminator cannot start. func RunDiscriminator(ctx context.Context, params Params) int { // Until the settings are read, the one message is an invalid setting's - // error, which every SWW_LOG_LEVEL lets through. + // error, which every SWW_WAF_LOG_LEVEL lets through. processLog := requestlog.NewProcessLogger(params.Stdout, - config.InstanceName(params.LookupEnv), slog.LevelError) + config.InstanceName(params.Viper), slog.LevelError) - cfg, err := config.FromEnvironment(params.LookupEnv) + cfg, err := config.FromEnvironment(params.Viper) if err != nil { processLog.Error("invalid setting", "error", err.Error()) return 1 } - // While SWW_LOG_REMOTE_URL is set, every line on stdout from here on + // While SWW_WAF_LOG_REMOTE_URL is set, every line on stdout from here on // is sent there too. stdout := params.Stdout @@ -178,7 +181,7 @@ func RunDiscriminator(ctx context.Context, params Params) int { listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) if err != nil { - processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error()) + processLog.Error("cannot listen on SWW_WAF_LISTEN_ADDR", "error", err.Error()) return 1 } @@ -192,7 +195,7 @@ func RunDiscriminator(ctx context.Context, params Params) int { } // newServer returns the server discriminator runs, with the metrics of the alerts, -// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup +// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup // database that cannot be read is an error. func newServer( cfg *config.Config, stdout io.Writer, processLog *slog.Logger, @@ -281,7 +284,7 @@ func newAlertQueue( } // newRemoteLogSender returns a sender of the log lines to -// SWW_LOG_REMOTE_URL, with the settings for it. +// SWW_WAF_LOG_REMOTE_URL, with the settings for it. func newRemoteLogSender(cfg *config.Config) *remotelog.Sender { return remotelog.New(remotelog.Params{ URL: cfg.LogRemoteURL, diff --git a/internal/sww/sww_test.go b/internal/sww/sww_test.go index 8638cfa..e8b879a 100644 --- a/internal/sww/sww_test.go +++ b/internal/sww/sww_test.go @@ -20,6 +20,7 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/config/configtest" "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/sww" @@ -34,21 +35,21 @@ const ( testVersion = "test" // localhost is where the tests listen. localhost = "127.0.0.1" - listenAddr = "SWW_LISTEN_ADDR" - stateDir = "SWW_STATE_DIR" - stateWriteDelay = "SWW_STATE_WRITE_DELAY" - stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" - rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" - rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" - rulesDir = "SWW_RULES_DIR" - lookupSource = "SWW_LOOKUP_SOURCE" - lookupDBPath = "SWW_LOOKUP_DB_PATH" - instanceName = "SWW_INSTANCE_NAME" - adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name - metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name - // adminSecret is the SWW_ADMIN_TOKEN the tests set. + listenAddr = "SWW_WAF_LISTEN_ADDR" + stateDir = "SWW_WAF_STATE_DIR" + stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY" + stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL" + rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY" + rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS" + rulesDir = "SWW_WAF_RULES_DIR" + lookupSource = "SWW_WAF_LOOKUP_SOURCE" + lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH" + instanceName = "SWW_WAF_INSTANCE_NAME" + adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name + metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name + // adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set. adminSecret = "fedcba9876543210fedcba9876543210" - // instance is the SWW_INSTANCE_NAME the tests set where they look at + // instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at // it. instance = "fsn1app1/blog" // greeting is what the tests' app answers. @@ -124,20 +125,18 @@ func (o *output) text() string { } // run runs `sww discriminator` with the settings in env until ctx is done, -// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it, +// and returns its exit status. SWW_WAF_LOOKUP_SOURCE is off unless env sets it, // so that no test sends GeoJS its clients' addresses. func run(ctx context.Context, env map[string]string, out *output) int { + v := configtest.Viper(env) + if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set { + v.Set("SWW_WAF_LOOKUP_SOURCE", "off") + } + return sww.RunDiscriminator(ctx, sww.Params{ Version: testVersion, - LookupEnv: func(name string) (string, bool) { - value, ok := env[name] - if !ok && name == "SWW_LOOKUP_SOURCE" { - return "off", true - } - - return value, ok - }, - Stdout: out, + Viper: v, + Stdout: out, }) } @@ -147,8 +146,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) { out := &output{} status := run(t.Context(), map[string]string{ - "SWW_BYTES_LIMIT_PER_MINUTE": "lots", - instanceName: instance, + "SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots", + instanceName: instance, }, out) if status != 1 { t.Errorf("exit status %d, want 1", status) @@ -159,7 +158,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) { if line["type"] != "process" || line["instance"] != instance || line["level"] != "ERROR" || - !strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") { + !strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") { t.Errorf("start refused with %v", line) } } @@ -215,7 +214,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) { t.Errorf("exit status %d, want 1", status) } - out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR") + out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR") } func TestServesUntilToldToStop(t *testing.T) { @@ -278,11 +277,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) { go func() { exited <- run(ctx, map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt", - "SWW_LOG_LEVEL": "warn", + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + "SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt", + "SWW_WAF_LOG_LEVEL": "warn", }, out) }() @@ -385,7 +384,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) { func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { t.Parallel() - const scope = "SWW_BAN_SCOPE_V4_PREFIX" + const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX" env := map[string]string{ listenAddr: localhost + ":0", @@ -505,7 +504,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) { rulesDir: dir, // The requests sent until the rule takes effect must not break a // rate limit, whose ban would refuse them too. - "SWW_RATE_LIMIT_PER_MINUTE": "off", + "SWW_WAF_RATE_LIMIT_PER_MINUTE": "off", } out := runUntilStopped(t, env, func(url string) { @@ -548,7 +547,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { dir := filepath.Join(t.TempDir(), "rules.d") - wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+ + wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+ ": no such file or directory") } @@ -560,14 +559,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) { status := run(t.Context(), map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), - "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path, + "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path, }, out) if status != 1 { t.Fatalf("exit status %d, want 1", status) } line := out.line(t, "msg", "cannot use the Core Rule Set") - want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " + + want := "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " + path + ": no such file or directory" if line["error"] != want { @@ -592,7 +591,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS status := run(t.Context(), map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), - "SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path, + "SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff, + "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path, }, out) if status != 1 { t.Fatalf("%s: exit status %d, want 1", path, status) @@ -611,14 +611,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { writeLookupDatabase(t, path, "DE") env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - metricsToken: token, - instanceName: instance, - lookupSource: "file", - lookupDBPath: path, - "SWW_TAG_DENY": "country=KP", + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + metricsToken: token, + instanceName: instance, + lookupSource: "file", + lookupDBPath: path, + "SWW_WAF_TAG_DENY": "country=KP", // The requests sent until a replacement takes effect, and those // for the metrics, must not break a rate limit, whose ban would // refuse them too. @@ -708,12 +708,12 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) torURL, dropURL := server.URL+torPath, server.URL+dropPath dir := t.TempDir() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: dir, - rulesDir: t.TempDir(), - metricsToken: token, - instanceName: instance, - "SWW_BLOCKLIST_URLS": torURL, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: t.TempDir(), + metricsToken: token, + instanceName: instance, + "SWW_WAF_BLOCKLIST_URLS": torURL, // The requests sent until the list takes effect, and those for the // metrics, must not break a rate limit, whose ban would refuse them // too. @@ -729,12 +729,12 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) }) // Restarted with drop.txt named after it and the server answering, tor.txt - // waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json, + // waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in reputation.json, // while drop.txt, never tried, is fetched at once. Lists are fetched in the // order named, so once drop.txt refuses the client, tor.txt has had its turn. failing.Store(false) - env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL + env["SWW_WAF_BLOCKLIST_URLS"] = torURL + "," + dropURL out := runUntilStopped(t, env, func(url string) { for statusFrom(t, url, placed) != http.StatusForbidden { time.Sleep(pollInterval) @@ -790,11 +790,11 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) { dir := t.TempDir() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: dir, - rulesDir: t.TempDir(), - "SWW_CROWDSEC_LAPI_URL": engine.URL, - "SWW_CROWDSEC_LAPI_KEY": bouncerKey, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: t.TempDir(), + "SWW_WAF_CROWDSEC_LAPI_URL": engine.URL, + "SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey, } // Once the list is fetched, the client's request bans it. @@ -868,7 +868,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) { t.Fatalf("exit status %d, want 1", status) } - want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path + + want := "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + path + ": no such file or directory" line := out.line(t, "msg", "cannot use the lookup database") @@ -890,10 +890,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { }() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + "SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), } out := runUntilStopped(t, env, func(url string) { @@ -945,13 +945,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { }() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), - "SWW_LOG_REMOTE_BUFFER": "1", - metricsToken: token, - instanceName: instance, + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + "SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), + "SWW_WAF_LOG_REMOTE_BUFFER": "1", + metricsToken: token, + instanceName: instance, } out := runUntilStopped(t, env, func(url string) { @@ -1004,12 +1004,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { dir := t.TempDir() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: dir, - rulesDir: rules, - "SWW_ALERT_WEBHOOK_URL": webhook.url, - "SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, - instanceName: instance, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: rules, + "SWW_WAF_ALERT_WEBHOOK_URL": webhook.url, + "SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, + instanceName: instance, } runUntilStopped(t, env, func(url string) { @@ -1057,7 +1057,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { webhook.failing.Store(false) - env["SWW_ALLOW_NETS"] = localhost + env["SWW_WAF_ALLOW_NETS"] = localhost env[metricsToken] = token runUntilStopped(t, env, func(url string) { @@ -1097,12 +1097,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { rulesDir: t.TempDir(), rateLimitPerDay: "1", // The metrics are read from 127.0.0.1, which no limit counts. - "SWW_ALLOW_NETS": localhost + "/32", - metricsToken: token, - instanceName: instance, - "SWW_ALERT_SLACK_WEBHOOK_URL": slack.url, - "SWW_ALERT_NTFY_URL": ntfy.url, - "SWW_ALERT_NTFY_TOKEN": ntfyToken, + "SWW_WAF_ALLOW_NETS": localhost + "/32", + metricsToken: token, + instanceName: instance, + "SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url, + "SWW_WAF_ALERT_NTFY_URL": ntfy.url, + "SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken, } runUntilStopped(t, env, func(url string) { @@ -1167,7 +1167,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) { t.Parallel() wantStartRefused(t, filepath.Join(t.TempDir(), "missing"), - "SWW_STATE_DIR cannot be written: ") + "SWW_WAF_STATE_DIR cannot be written: ") } // wantStartRefused runs sww with its state files in dir, and @@ -1273,28 +1273,28 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) { settings, _ := line["settings"].(map[string]any) want := map[string]any{ - listenAddr: localhost + ":0", - stateDir: dir, - "SWW_MODE": "enforce", - stateWriteDelay: "10s", - stateCounterInterval: "15m", - "SWW_ALLOW_NETS": "", - rateLimitExemptNets: "", - "SWW_DENY_NETS": "", - "SWW_RATE_LIMIT_PER_MINUTE": "120", - "SWW_RATE_LIMIT_PER_HOUR": "1500", - rateLimitPerDay: "6000", - "SWW_RATE_LIMIT_EXEMPT_PATHS": "", - "SWW_TAG_DENY": "", - "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "", - "SWW_BAN_RESPONSE": "403", - "SWW_LIMIT_BAN_DURATION": "1h", - "SWW_LIMIT_BAN_REPEAT_WINDOW": "24h", - "SWW_MAX_BAN_DURATION": "7d", - "SWW_ATTACK_BAN_DURATION": "7d", - "SWW_MAX_BANS": "5000", - "SWW_BAN_SCOPE_V4_PREFIX": "32", - "SWW_RULES_ENABLED": "true", + listenAddr: localhost + ":0", + stateDir: dir, + "SWW_WAF_MODE": "enforce", + stateWriteDelay: "10s", + stateCounterInterval: "15m", + "SWW_WAF_ALLOW_NETS": "", + rateLimitExemptNets: "", + "SWW_WAF_DENY_NETS": "", + "SWW_WAF_RATE_LIMIT_PER_MINUTE": "120", + "SWW_WAF_RATE_LIMIT_PER_HOUR": "1500", + rateLimitPerDay: "6000", + "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "", + "SWW_WAF_TAG_DENY": "", + "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", + "SWW_WAF_BAN_RESPONSE": "403", + "SWW_WAF_LIMIT_BAN_DURATION": "1h", + "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h", + "SWW_WAF_MAX_BAN_DURATION": "7d", + "SWW_WAF_ATTACK_BAN_DURATION": "7d", + "SWW_WAF_MAX_BANS": "5000", + "SWW_WAF_BAN_SCOPE_V4_PREFIX": "32", + "SWW_WAF_RULES_ENABLED": "true", } for name, value := range want { @@ -1494,7 +1494,7 @@ func onlyBan(t *testing.T, dir string) map[string]any { } // wantRefused checks that discriminator at url refuses a request from localhost with -// 403, the default SWW_BAN_RESPONSE. +// 403, the default SWW_WAF_BAN_RESPONSE. func wantRefused(t *testing.T, url string) { t.Helper() @@ -1535,8 +1535,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int } } -// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or -// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers +// destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or +// SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers // 200. type destination struct { url string @@ -1595,7 +1595,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost { } } -// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert +// webhook is a stand-in for SWW_WAF_ALERT_WEBHOOK_URL. It notes each alert // it is sent, and answers 204, or 503 while failing. type webhook struct { url string diff --git a/script/example-app b/script/example-app index 3360636..d813d86 100755 --- a/script/example-app +++ b/script/example-app @@ -16,10 +16,10 @@ # - that a new discriminator container on the same volume still refuses the banned # client; # - that a discriminator container with its state files on a new volume mounted at -# the directory SWW_STATE_DIR_FILE names becomes healthy; -# - that one whose SWW_STATE_DIR does not exist logs discriminator's message +# the directory SWW_WAF_STATE_DIR_FILE names becomes healthy; +# - that one whose SWW_WAF_STATE_DIR does not exist logs discriminator's message # about it. -# discriminator runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to +# discriminator runs with SWW_WAF_LOOKUP_SOURCE=off, so that no address is sent to # GeoJS. The containers, the network, the volumes and both images are removed # however the script ends. Building the app needs network access, for nixpkgs' # binary cache. script/check does not run this. @@ -37,7 +37,7 @@ NETWORK="$NAME" APP="$NAME-app-container" DISCRIMINATOR="$NAME-discriminator" VOLUME="$NAME-state" -# The volumes of the container whose state directory SWW_STATE_DIR_FILE +# The volumes of the container whose state directory SWW_WAF_STATE_DIR_FILE # names: its state files, and the file that names their directory. MOVED_VOLUME="$NAME-moved-state" SETTINGS_VOLUME="$NAME-settings" @@ -94,8 +94,8 @@ start_discriminator() { docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \ --volume "$VOLUME:/var/lib/sww" \ --env SWW_COMMAND=discriminator \ - --env SWW_RATE_LIMIT_PER_MINUTE=1 \ - --env SWW_LOOKUP_SOURCE=off \ + --env SWW_WAF_RATE_LIMIT_PER_MINUTE=1 \ + --env SWW_WAF_LOOKUP_SOURCE=off \ "$IMAGE" >/dev/null wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" } @@ -104,14 +104,14 @@ start_discriminator() { # healthy. start_app() { docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \ - --env SWRP_WAF_BACKEND="http://$DISCRIMINATOR:8080" \ + --env SWW_RPROXY_WAF_BACKEND="http://$DISCRIMINATOR:8080" \ "$APP_IMAGE" >/dev/null wait_for "the app's health check did not pass" healthy "$APP" address="$(docker port "$APP" 8080/tcp)" } # start_moved_discriminator: run discriminator with the state files on a new volume at -# /srv/state, which SWW_STATE_DIR_FILE names in a file only the sww +# /srv/state, which SWW_WAF_STATE_DIR_FILE names in a file only the sww # user can read, as under "Settings given as files" in docs/settings.md, and # wait until it is healthy. discriminator can write the new volume only once the run # script has given it to that user. @@ -126,8 +126,8 @@ start_moved_discriminator() { --volume "$MOVED_VOLUME:/srv/state" \ --volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \ --env SWW_COMMAND=discriminator \ - --env SWW_STATE_DIR_FILE=/etc/sww/settings/state-dir \ - --env SWW_LOOKUP_SOURCE=off \ + --env SWW_WAF_STATE_DIR_FILE=/etc/sww/settings/state-dir \ + --env SWW_WAF_LOOKUP_SOURCE=off \ "$IMAGE" >/dev/null wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" } @@ -140,7 +140,7 @@ status_from() { --max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true } -# refused: a request to the container gets 403, SWW_BAN_RESPONSE's +# refused: a request to the container gets 403, SWW_WAF_BAN_RESPONSE's # default. refused() { code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ @@ -204,7 +204,7 @@ main() { wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"' [ "$(status_from 203.0.113.9 /)" = 200 ] || fail "with discriminator stopped, the app did not answer through rproxy" - wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \ + wait_for "rproxy logged no request SWW_RPROXY_DEFAULT_DISPOSITION decided" \ logged "$APP" '"default_disposition":true' docker exec "$APP" /usr/local/bin/sww healthcheck || fail "with discriminator stopped, the app's health check failed" @@ -239,16 +239,16 @@ main() { docker rm --force "$DISCRIMINATOR" >/dev/null start_moved_discriminator echo "example-app: the run script gives the state files' directory named" \ - "by SWW_STATE_DIR_FILE to the sww user" + "by SWW_WAF_STATE_DIR_FILE to the sww user" docker rm --force "$DISCRIMINATOR" >/dev/null docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \ --env SWW_COMMAND=discriminator \ - --env SWW_STATE_DIR=/srv/missing \ - --env SWW_LOOKUP_SOURCE=off \ + --env SWW_WAF_STATE_DIR=/srv/missing \ + --env SWW_WAF_LOOKUP_SOURCE=off \ "$IMAGE" >/dev/null wait_for "discriminator did not report the missing state directory" \ - logged "$DISCRIMINATOR" "SWW_STATE_DIR cannot be written" + logged "$DISCRIMINATOR" "SWW_WAF_STATE_DIR cannot be written" echo "example-app: discriminator reports a state directory that does not exist" } diff --git a/script/run b/script/run index 5c00807..b9fe1ef 100755 --- a/script/run +++ b/script/run @@ -2,11 +2,11 @@ # script/run: build bin/sww with script/build and run discriminator and # rproxy on the host, with the settings in the environment, until rproxy # exits or the script is told to stop. rproxy listens on port 8080 unless -# SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL. -# discriminator listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and -# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless -# SWW_STATE_DIR is set, the state files go in bin/state, beside the binary, -# and unless SWW_RULES_DIR is set, the rule files are those of share/rules.d, +# SWW_RPROXY_LISTEN_ADDR says otherwise, in front of the app at SWW_RPROXY_UPSTREAM_URL. +# discriminator listens on port 8090 unless SWW_WAF_LISTEN_ADDR says otherwise, and +# rproxy asks it there unless SWW_RPROXY_WAF_BACKEND says otherwise. Unless +# SWW_WAF_STATE_DIR is set, the state files go in bin/state, beside the binary, +# and unless SWW_WAF_RULES_DIR is set, the rule files are those of share/rules.d, # which the image ships. set -eu @@ -26,18 +26,18 @@ stop_both() { main() { "$SCRIPT_DIR/build" - if [ -z "${SWW_STATE_DIR+set}" ]; then - SWW_STATE_DIR="$ROOT/bin/state" - export SWW_STATE_DIR - mkdir -p "$SWW_STATE_DIR" + if [ -z "${SWW_WAF_STATE_DIR+set}" ]; then + SWW_WAF_STATE_DIR="$ROOT/bin/state" + export SWW_WAF_STATE_DIR + mkdir -p "$SWW_WAF_STATE_DIR" fi - if [ -z "${SWW_RULES_DIR+set}" ]; then - SWW_RULES_DIR="$ROOT/share/rules.d" - export SWW_RULES_DIR + if [ -z "${SWW_WAF_RULES_DIR+set}" ]; then + SWW_WAF_RULES_DIR="$ROOT/share/rules.d" + export SWW_WAF_RULES_DIR fi - SWW_LISTEN_ADDR="${SWW_LISTEN_ADDR:-:8090}" - SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWW_LISTEN_ADDR##*:}}" - export SWW_LISTEN_ADDR SWRP_WAF_BACKEND + SWW_WAF_LISTEN_ADDR="${SWW_WAF_LISTEN_ADDR:-:8090}" + SWW_RPROXY_WAF_BACKEND="${SWW_RPROXY_WAF_BACKEND:-http://127.0.0.1:${SWW_WAF_LISTEN_ADDR##*:}}" + export SWW_WAF_LISTEN_ADDR SWW_RPROXY_WAF_BACKEND trap stop_both EXIT trap 'exit 1' HUP INT TERM diff --git a/share/sww.run b/share/sww.run index 5b31932..abbe0c4 100755 --- a/share/sww.run +++ b/share/sww.run @@ -24,7 +24,7 @@ main() { # give_state_dir_to_sww gives the state directory and every file in # it to the sww user, so that a volume mounted there needs no change # of owner; chown -R changes a symbolic link itself, never what it points -# to. When SWW_STATE_DIR, in either form, cannot be read or is not the +# to. When SWW_WAF_STATE_DIR, in either form, cannot be read or is not the # absolute path of a directory, no owner is changed, and discriminator starts and # stops with a message naming the setting. A change of owner that fails, as # on a volume mounted read-only, does not end the script either: discriminator @@ -36,14 +36,14 @@ give_state_dir_to_sww() { fi } -# state_dir prints SWW_STATE_DIR, /var/lib/sww when it is unset, -# or what is in the file SWW_STATE_DIR_FILE names, less every newline at +# state_dir prints SWW_WAF_STATE_DIR, /var/lib/sww when it is unset, +# or what is in the file SWW_WAF_STATE_DIR_FILE names, less every newline at # its end. It fails when both are set or the file cannot be read. state_dir() { - if [[ ! -v SWW_STATE_DIR_FILE ]]; then - printf '%s\n' "${SWW_STATE_DIR-/var/lib/sww}" - elif [[ ! -v SWW_STATE_DIR ]]; then - cat -- "$SWW_STATE_DIR_FILE" 2>/dev/null + if [[ ! -v SWW_WAF_STATE_DIR_FILE ]]; then + printf '%s\n' "${SWW_WAF_STATE_DIR-/var/lib/sww}" + elif [[ ! -v SWW_WAF_STATE_DIR ]]; then + cat -- "$SWW_WAF_STATE_DIR_FILE" 2>/dev/null else return 1 fi