diff --git a/Dockerfile b/Dockerfile index 300708e..ef41ce8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,7 +87,7 @@ COPY . . # .git present, a version that is still empty, dev or unknown fails the # build: git is missing or could not read the checkout. The no_fs_access # tag keeps Coraza from writing the files of a multipart body to the -# system's temporary directory, since smallwebwaf writes only to its state +# system's temporary directory, since sww writes only to its state # directory. ARG VERSION RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ @@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ fi; \ CGO_ENABLED=0 go build -tags no_fs_access -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf + -o /usr/local/bin/sww ./cmd/sww # runsvinit, the image's entrypoint, built at the last commit of its # archived repository. It has no go.mod, and `go build` of its directory @@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \ -o /usr/local/bin/runsvinit . # The image, described under "Deployment" in SPEC.md. It runs rproxy unless -# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it. +# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it. # It is the last stage, so a plain `docker build .` builds it. # # ubuntu 26.04, 2026-09-27 @@ -166,27 +166,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \ ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit -COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf +COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww # 65532 is above the uids Ubuntu keeps for system users, which end at # 999; useradd warns about it unless --key raises that end for this call. -RUN groupadd --system --gid 65532 smallwebwaf \ +RUN groupadd --system --gid 65532 sww \ && useradd --system --key SYS_UID_MAX=65532 --uid 65532 \ - --gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \ - smallwebwaf + --gid sww --no-create-home --shell /usr/sbin/nologin \ + sww -# The state files' directory, SWWAF_STATE_DIR by default, where a volume +# The state files' directory, SWW_STATE_DIR by default, where a volume # is mounted to keep them across deploys. The run script gives it to the -# smallwebwaf user at each start of servewaf. -RUN mkdir /var/lib/smallwebwaf +# sww user at each start of discriminator. +RUN mkdir /var/lib/sww -# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile +# The default rule file, in SWW_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/smallwebwaf/rules.d/00-default.rules +COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules # runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv # looks too. -COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run +COPY --chmod=755 share/sww.run /etc/service/sww/run EXPOSE 8080 @@ -194,6 +194,6 @@ EXPOSE 8080 # check runs every second from the start until it first passes, for up # to a minute, and every 30 seconds after that. HEALTHCHECK --start-period=1m --start-interval=1s \ - CMD ["/usr/local/bin/smallwebwaf", "healthcheck"] + CMD ["/usr/local/bin/sww", "healthcheck"] ENTRYPOINT ["/usr/local/bin/runsvinit"] diff --git a/README.md b/README.md index 9aca07b..eb47302 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,17 @@ A web application firewall reads each request on its way to a web app and refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in one image, with two commands that do this together: -- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik - routes to it. It asks `servewaf` about each request, passes the allowed ones - to the app, and passes the app's answers back. It keeps nothing and never - touches disk. -- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the - counters and each client's history. One `servewaf` serves many sites: a client - banned through one site is refused on all of them, and its requests on all of - them count toward one set of limits. +- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to + it. It asks `discriminator` about each request, passes the allowed ones to the + app, and passes the app's answers back. It keeps nothing and never touches + disk. +- `sww discriminator` decides. It runs the checks and keeps the bans, the + counters and each client's history. One `discriminator` serves many sites: a + client banned through one site is refused on all of them, and its requests on + all of them count toward one set of limits. -The site keeps answering while `servewaf` is down, slow or restarting: without -an answer in time, `rproxy` passes the request to the app. +The site keeps answering while `discriminator` is down, slow or restarting: +without an answer in time, `rproxy` passes the request to the app. It protects against: @@ -79,11 +79,11 @@ make docker Push the image to a registry your hosts pull from, and name it there by digest. -The image runs `rproxy` by default. To run `servewaf` from the same image, set -the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check +The image runs `rproxy` by default. To run `discriminator` from the same image, +set the container's `SWW_COMMAND` to `discriminator`. The image's health check follows the command: in `rproxy`'s container it passes once `rproxy` answers and -the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s -container, once `servewaf` answers. +the app accepts connections, whether or not `discriminator` answers; in +`discriminator`'s container, once `discriminator` answers. ### The usual setup: three containers @@ -91,24 +91,24 @@ 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 `servewaf` and `SWRP_UPSTREAM_URL` names the app; -- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at - `/var/lib/smallwebwaf` for its state files. + names `discriminator` and `SWRP_UPSTREAM_URL` names the app; +- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at + `/var/lib/sww` for its state files. Point traefik's labels at port 8080 of `rproxy` (`traefik.http.services..loadbalancer.server.port=8080`). Publish no port -of the app or of `servewaf`. Anything on that network can reach the app around -`rproxy`, and can ask `servewaf` 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 `servewaf`. The "Smallest setup" example below -shows this shape. +of the app or of `discriminator`. Anything on that network can reach the app +around `rproxy`, and can ask `discriminator` questions, so the network holds +only what you trust. Each further app adds its own container and its own +`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest +setup" example below shows this shape. ### `rproxy` in the app's container An app's image can instead be built on the `smallwebwaf` image, so that `rproxy` -runs beside the app in one container. `servewaf` still runs in a container of -its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by -digest, and adds: +runs beside the app in one container. `discriminator` still runs in a container +of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named +by digest, and adds: - the app's binary; - the packages the app needs, from the nixpkgs in the image; @@ -168,16 +168,16 @@ does. - A log line with `"msg":"starting"` gives the version, the address and every setting in effect, with secrets masked. - A request to the site through traefik gets the app's answer. - `https:///_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines - about the request have `"action":"forward"`. -- To see a refusal without banning yourself, run `servewaf` with - `SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. - `servewaf`'s log lines have `"would_action":"banned"` and + `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. + `discriminator`'s log lines have `"would_action":"banned"` and `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as "Clients and bans" below describes. -- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on - `servewaf`'s network: - `curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`. +- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on + `discriminator`'s network: + `curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`. - `make example-app` builds the image and an app on it, runs them, and checks the whole exchange (see "Entrypoints"). @@ -185,15 +185,15 @@ does. Every setting is an environment variable, read once at start; changing one means restarting the container. `rproxy` reads only the `SWRP_` settings, and -`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a +`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen for a service on the open internet. A setting that needs an account, a key, a token or an alert destination stays off until you give one. -- Any `SWWAF_` setting can be given as a file instead, for secrets and long - lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. - The file's contents are the value, less one newline at the end. Setting both - forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and - `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no +- 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. No `SWRP_` setting has one, since `rproxy` never touches disk. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of @@ -214,122 +214,122 @@ account, a key, a token or an alert destination stays off until you give one. Every setting the code reads, with its default: -| Variable | Default | What it does | -| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. | -| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf: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 `servewaf`'s answer to a question and to a report, in milliseconds. | -| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | -| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | -| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. | -| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. | -| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | -| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. | -| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. | -| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | -| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | -| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | -| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. | -| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | -| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | -| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. | -| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. | -| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. | -| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. | -| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | -| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. | -| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | -| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | -| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. | -| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. | -| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | -| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | -| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. | -| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | -| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | -| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | -| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. | -| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | -| `SWWAF_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. | -| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | -| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. | -| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | -| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. | -| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. | -| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. | -| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. | -| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | -| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS:` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | -| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. | -| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. | -| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | -| `SWWAF_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. | -| `SWWAF_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. | -| `SWWAF_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. | -| `SWWAF_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. | -| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | -| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | -| `SWWAF_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. | -| `SWWAF_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`. | -| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | -| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:` of every limit, or `log`. | -| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | -| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. | -| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | -| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | -| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | -| `SWWAF_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. | -| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. | -| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:` or `log`. | -| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | -| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | -| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. | -| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | -| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | -| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | -| `SWWAF_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. | -| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | -| `SWWAF_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. | -| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | -| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | -| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | -| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | -| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. | -| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. | -| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | -| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer `. | -| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | -| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | -| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | -| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | -| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | -| `SWWAF_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`. | -| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | -| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | -| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. | -| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. | -| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | -| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | -| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. | -| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. | -| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. | -| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. | -| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | -| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | -| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. | -| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. | -| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | -| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | -| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. | -| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. | -| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | -| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | -| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | -| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. | -| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this 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`. | +| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | +| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | +| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | +| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | +| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | +| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | +| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | +| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. | +| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. | +| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | +| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | +| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | +| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | +| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | +| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | +| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | +| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | +| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | +| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | +| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. | +| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. | +| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. | +| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | +| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. | +| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | +| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | +| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. | +| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. | +| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | +| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | +| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. | +| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | +| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | +| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | +| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. | +| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | +| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | +| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | +| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. | +| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | +| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. | +| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. | +| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. | +| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. | +| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | +| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS:` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | +| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | +| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. | +| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | +| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. | +| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. | +| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | +| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | +| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | +| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | +| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | +| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | +| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | +| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:` 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,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | +| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | +| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | +| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. | +| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. | +| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. | +| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. | +| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. | +| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. | +| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. | +| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. | +| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | +| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | +| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. | +| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. | +| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | +| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | +| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | +| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. | +| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. | [`docs/settings.md`](docs/settings.md) gives the values each setting takes in full, and the limits that are fixed rather than settings. @@ -344,8 +344,8 @@ own, where each reaches the others by service name. Each example publishes ### Smallest setup -One app, `rproxy` in front of it, and `servewaf` with a volume for its state -files, every setting at its default. +One app, `rproxy` in front of it, and `discriminator` with a volume for its +state files, every setting at its default. ```yaml services: @@ -356,20 +356,20 @@ services: rproxy: image: smallwebwaf environment: - SWRP_WAF_BACKEND: http://servewaf:8080 + SWRP_WAF_BACKEND: http://discriminator:8080 SWRP_UPSTREAM_URL: http://app:8081 ports: - "8080:8080" - servewaf: + discriminator: image: smallwebwaf environment: - SMALLWEBWAF_COMMAND: servewaf + SWW_COMMAND: discriminator volumes: - - servewaf-state:/var/lib/smallwebwaf + - discriminator-state:/var/lib/sww volumes: - servewaf-state: + discriminator-state: ``` ### Custom per-address limits @@ -392,46 +392,46 @@ services: rproxy: image: smallwebwaf environment: - SWRP_WAF_BACKEND: http://servewaf:8080 + SWRP_WAF_BACKEND: http://discriminator:8080 SWRP_UPSTREAM_URL: http://app:8081 ports: - "8080:8080" - servewaf: + discriminator: image: smallwebwaf environment: - SMALLWEBWAF_COMMAND: servewaf + SWW_COMMAND: discriminator # Monitoring skips every check. - SWWAF_ALLOW_NETS: 192.0.2.10 + SWW_ALLOW_NETS: 192.0.2.10 # The office and the automation host are never rate limited. - SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 + SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 # A netblock that sends nothing wanted. - SWWAF_DENY_NETS: 198.51.100.128/25 + SWW_DENY_NETS: 198.51.100.128/25 # Tighter limits for everyone else: a small static site. - SWWAF_RATE_LIMIT_PER_MINUTE: "60" - SWWAF_RATE_LIMIT_PER_HOUR: "600" - SWWAF_RATE_LIMIT_PER_DAY: "3000" + SWW_RATE_LIMIT_PER_MINUTE: "60" + SWW_RATE_LIMIT_PER_HOUR: "600" + SWW_RATE_LIMIT_PER_DAY: "3000" # Hosting networks and one country get a share of those limits. - SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 - SWWAF_COUNTRY_LIMIT_PERCENT: cn:25 + SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 + SWW_COUNTRY_LIMIT_PERCENT: cn:25 # Paths only scanners ask for; one request bans the client. - SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php + SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php # Answer a refused client with 429 rather than 403. - SWWAF_BAN_RESPONSE: "429" - SWWAF_LIMIT_BAN_DURATION: 30m + SWW_BAN_RESPONSE: "429" + SWW_LIMIT_BAN_DURATION: 30m volumes: - - servewaf-state:/var/lib/smallwebwaf + - discriminator-state:/var/lib/sww volumes: - servewaf-state: + discriminator-state: ``` ### `rproxy` beside the app The app is built on the `smallwebwaf` image, with `rproxy` inside its container. `app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's -container". `rproxy` answers `503` to every request while `servewaf` is down, -and `servewaf`: +container". `rproxy` answers `503` to every request while `discriminator` is +down, and `discriminator`: - observes before it enforces; - looks addresses up in a local database rather than GeoJS; @@ -444,46 +444,46 @@ services: # A Dockerfile built FROM the smallwebwaf image, with rproxy inside. build: ./app environment: - SWRP_WAF_BACKEND: http://servewaf:8080 - # Answer 503 rather than pass a request on while servewaf is down. + SWRP_WAF_BACKEND: http://discriminator:8080 + # Answer 503 rather than pass a request on while discriminator is down. SWRP_DEFAULT_DISPOSITION: deny ports: - "8080:8080" - servewaf: + discriminator: image: smallwebwaf environment: - SMALLWEBWAF_COMMAND: servewaf - SWWAF_INSTANCE_NAME: fsn1/servewaf + SWW_COMMAND: discriminator + SWW_INSTANCE_NAME: fsn1/discriminator # Refuse nothing yet; log what enforce mode would have done. - SWWAF_MODE: observe + SWW_MODE: observe # Look addresses up in a local IPinfo Lite database, never GeoJS. - SWWAF_LOOKUP_SOURCE: file - SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb + SWW_LOOKUP_SOURCE: file + SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb # Alerts to an ntfy topic, with its token read from a file. - SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf - SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy - SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error + SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf + SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy + SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error volumes: - - servewaf-state:/var/lib/smallwebwaf + - discriminator-state:/var/lib/sww - /srv/ipinfo:/var/lib/ipinfo:ro - - /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro + - /srv/sww/tokens:/etc/sww/tokens:ro volumes: - servewaf-state: + discriminator-state: ``` The database is mounted by its directory, not by file, so that a replaced -database is seen. A token file belongs to uid 65532, the `smallwebwaf` user, -with mode `0400`. This one holds an access token the ntfy server issued, which +database is seen. A token file belongs to uid 65532, the `sww` user, with mode +`0400`. This one holds an access token the ntfy server issued, which `ntfy token add ` prints there: ```sh -mkdir -p /srv/smallwebwaf/tokens +mkdir -p /srv/sww/tokens # is the access token that ntfy printed. -echo '' > /srv/smallwebwaf/tokens/ntfy -chown 65532:65532 /srv/smallwebwaf/tokens/ntfy -chmod 0400 /srv/smallwebwaf/tokens/ntfy +echo '' > /srv/sww/tokens/ntfy +chown 65532:65532 /srv/sww/tokens/ntfy +chmod 0400 /srv/sww/tokens/ntfy ``` ## Design @@ -492,20 +492,20 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why. ### What happens to a request -A request goes through these steps, in this order. Once `servewaf` refuses a -request, the steps after that are skipped, apart from the report, and nothing of -the request reaches the app. +A request goes through these steps, in this order. Once `discriminator` refuses +a request, the steps after that are skipped, apart from the report, and nothing +of the request reaches the app. `rproxy` takes the first two: -1. The health check, `GET /_smallwebwaf/healthz`, is answered at once. +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 `servewaf` the request as the - visitor sent it, with the first 64 KiB of its body. + from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as + the visitor sent it, with the first 64 KiB of its body. -`servewaf` takes the checks: +`discriminator` takes the checks: -3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`. +3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. 4. The bans. 5. The lookup of the client's AS number and country. 6. The country lists. @@ -516,30 +516,30 @@ the request reaches the app. 10. The trap paths. 11. The rule files. 12. The header limits, and then the Core Rule Set. -13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees - that prefix. +13. A path under `/_sww/` is answered `404`, so that the app never sees that + prefix. -`rproxy` takes the rest, with `servewaf` counting at the end: +`rproxy` takes the rest, with `discriminator` counting at the end: 14. The request is passed to the app, streaming. -15. After the answer, `rproxy` sends `servewaf` a report with the status and the - bytes each way. For an allowed request, `servewaf` counts the bytes toward - the byte limits and adds the request to the client's history. +15. After the answer, `rproxy` sends `discriminator` a report with the status + and the bytes each way. For an allowed request, `discriminator` counts the + bytes toward the byte limits and adds the request to the client's history. -A refused visitor is answered with `SWWAF_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 `SWWAF_ERROR_BURST_THRESHOLD` -instead. A request over the header limits of step 12 is answered `431`, and bans -nobody either. `rproxy` itself answers `405` to a method it does not pass on, -and the status for a size or time limit, such as `413` for a body too large. +A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core +Rule Set match answers `403` and bans nobody, since the Core Rule Set has false +positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A +request over the header limits of step 12 is answered `431`, and bans nobody +either. `rproxy` itself answers `405` to a method it does not pass on, and the +status for a size or time limit, such as `413` for a body too large. ### Clients and bans -- A client is one IPv4 address or one IPv6 netblock of - `SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64. +- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`, + since one abuser usually controls a whole /64. - Each limit is counted over a sliding window with a few integers per client. The table of clients and the table of bans each have a bound - (`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too. + (`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too. - A ban refuses every request from its netblock until it ends. Requests refused under a ban count toward no limit. A permanent ban ends only when an admin lifts it. @@ -551,7 +551,7 @@ and the status for a size or time limit, such as `413` for a body too large. ### 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 `SWWAF_WAF_BODY_LIMIT` is set. In + headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In front of a code forge, bodies carry what people write and publish, which the rules take for attacks. - `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary @@ -559,20 +559,20 @@ and the status for a size or time limit, such as `413` for a body too large. 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. -- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's - own fields or paths. +- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own + fields or paths. - Rule files are plain text, one rule to a line, edited by hand and read again - while `servewaf` runs. The image ships one file of probes no real visitor + while `discriminator` runs. The image ships one file of probes no real visitor sends (see [`docs/rules.md`](docs/rules.md)). ### State files -`servewaf` keeps everything in memory, and the five JSON files in -`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, +`discriminator` keeps everything in memory, and the five JSON files in +`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, `reputation.json` and `alerts.json`. Each is written whole, to a temporary file -renamed over the real one, so a crash leaves a complete file. `servewaf` notices -a file edited on disk and takes the edit in; an edit that does not parse is set -aside as `.bad`, and never stops the running process (see +renamed over the real one, so a crash leaves a complete file. `discriminator` +notices a file edited on disk and takes the edit in; an edit that does not parse +is set aside as `.bad`, and never stops the running process (see [`docs/state-files.md`](docs/state-files.md)). ### The image and its processes @@ -581,30 +581,30 @@ aside as `.bad`, and never stops the running process (see come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's `Dockerfile` installs a package with `nix-env -iA nixpkgs.` and gets the same package each time it is built (see [`docs/image.md`](docs/image.md)). -- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid - and gid 65532) and starts it again when it exits. The `run` script of - `servewaf` gives the state directory to the `smallwebwaf` user at each start, - so a volume needs no change of owner. -- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints, - under `/_smallwebwaf/`, are for your admin tools and your metrics collector, - at its own address on the private network; nothing routes the internet there. +- runit, a service supervisor, runs `smallwebwaf` as the `sww` user (uid and + gid 65532) and starts it again when it exits. The `run` script of + `discriminator` gives the state directory to the `sww` user at each start, so + a volume needs no change of owner. +- `rproxy` and `discriminator` use no token between them. `discriminator`'s own + endpoints, under `/_sww/`, are for your admin tools and your metrics + collector, at its own address on the private network; nothing routes the + internet there. - `docker stop` gives requests in progress five seconds to finish. With `rproxy` in the app's container, the app must keep answering for five seconds after `SIGTERM` for that to help them. - Only faults found at start stop a process: an invalid setting, a rule file or state file that does not parse, a lookup database that cannot be read, or an address it cannot listen on. Once running, a failing lookup service, - reputation source or alert destination never takes `servewaf` down. + reputation source or alert destination never takes `discriminator` down. ### Packages -- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. -- `internal/smallwebwaf`: the process, as the command it is given, and the - health check. +- `cmd/sww`: the binary, which only calls `internal/sww`. +- `internal/sww`: the process, as the command it is given, and the health check. - `internal/config`: reads the settings, the one place they are read. - `internal/rproxy`: the reverse proxy in front of one app. -- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers - its own endpoints. +- `internal/discriminator`: `discriminator`'s server, which runs the checks and + answers its own endpoints. - `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`, `internal/lookup`, `internal/reputation`, `internal/anomaly` and `internal/alerts`: one check or source each. @@ -668,27 +668,26 @@ on the host. image build. - `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/install-precommit`: installs that hook. `make hooks`. -- `script/build`: builds `bin/smallwebwaf` with the Go on your host. - `make build`. -- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the +- `script/build`: builds `bin/sww` with the Go on your host. `make build`. +- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the host, with the settings in your environment. `make run`. Unless your settings say otherwise: - `rproxy` listens on port 8080, in front of an app at `http://127.0.0.1:8081`; - - `servewaf` listens on port 8090; + - `discriminator` listens on port 8090; - its state files go in `bin/state`; - its rule files are those of `share/rules.d`. - `script/example-app`: builds the image and the example app (`deploy/example-app`) on it, runs the app with `rproxy` in its container and - `servewaf` in another, and checks them. Then it removes the containers, the - network, the volumes and the images. Needs network access. `make example-app`. - It checks: + `discriminator` in another, and checks them. Then it removes the containers, + the network, the volumes and the images. Needs network access. + `make example-app`. It checks: - the health checks; - a request passed to the app; - a ban for a broken limit, and a ban for a probe made permanent; - - that only `servewaf`'s container changes the state directory's owner; - - with `servewaf` stopped, the app still answering through `rproxy`; - - with `servewaf` started again, bans applying again; + - that only `discriminator`'s container changes the state directory's owner; + - with `discriminator` stopped, the app still answering through `rproxy`; + - with `discriminator` started again, bans applying again; - orderly stops with `sv stop` and `docker stop`; - bans surviving a new container on the same volume; - a setting given as a file. @@ -707,7 +706,7 @@ on the host. - [`docs/settings.md`](docs/settings.md): every setting and the values it takes. - [`docs/rules.md`](docs/rules.md): the rule file format and the default file. - [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - `/_smallwebwaf/`, with examples. + `/_sww/`, with examples. - [`docs/request-log.md`](docs/request-log.md): the request log's fields. - [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination gets. diff --git a/SPEC.md b/SPEC.md index 9003ed4..874b84d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,8 +3,8 @@ `smallwebwaf` is a web application firewall for self-hosted apps. It is one binary, and two of its commands do the work: -- `smallwebwaf rproxy` is a reverse proxy between traefik and one app. -- `smallwebwaf servewaf` decides what happens to each request, for many sites. +- `sww rproxy` is a reverse proxy between traefik and one app. +- `sww discriminator` decides what happens to each request, for many sites. This document says what it must do and why, each requirement once. Everything in it is built, except what the last section lists. `README.md` says how to run it. @@ -16,12 +16,12 @@ to them. - It protects self-hosted apps on the open internet from scrapers, scanners and other abusers. It does so from an app's first request. `rproxy` requires only - the address of `servewaf`. In a container of its own it also needs the app's - address. Every other setting has a default chosen for a service facing the - internet in 2026. A setting that needs an alert destination, an account key or - a token stays off until the operator supplies it. + the address of `discriminator`. In a container of its own it also needs the + app's address. Every other setting has a default chosen for a service facing + the internet in 2026. A setting that needs an alert destination, an account + key or a token stays off until the operator supplies it. - It keeps a site answering while the part that decides is down, slow or - restarting (see "Without an answer from `servewaf`"). + restarting (see "Without an answer from `discriminator`"). - It limits each client's requests and bytes per minute, hour and day. It bounds the size and duration of every request and answer. It detects attacks. - It bans abusers: briefly at first, for seven days on a clear sign of attack, @@ -29,15 +29,15 @@ to them. - It looks up each client's AS number and country. It can refuse whole countries 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 `servewaf`'s start - with `SWWAF_`, and those of `rproxy` with `SWRP_`. The only other inputs are - `servewaf`'s files: +- 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 `SWWAF_` setting given as a file; - - the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names; - - the file `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names. + - 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. ## Non-goals @@ -51,29 +51,28 @@ to them. ## Architecture -- One statically linked Go binary. Only `servewaf` writes to disk, and only to - its state directory. It ships in one image, which runs either command (see +- One statically linked Go binary. Only `discriminator` writes to disk, and only + to its state directory. It ships in one image, which runs either command (see "Deployment"). - No database of any kind. Nothing is read from disk while serving a request. - Packages: - - `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. - - `internal/smallwebwaf`: the process, as the command it is given. - `smallwebwaf servewaf` reads the settings, the rule files, the lookup + - `cmd/sww`: the binary, which only calls `internal/sww`. + - `internal/sww`: the process, as the command it is given. + `sww discriminator` reads the settings, the rule files, the lookup database and the state files, listens, and serves until `SIGTERM` or - `SIGINT`. Then it stops, writing the state files (see "`servewaf`"). - `smallwebwaf rproxy` reads only that command's settings, and runs + `SIGINT`. Then it stops, writing the state files (see "`discriminator`"). + `sww rproxy` reads only that command's settings, and runs `internal/rproxy` until `SIGTERM` or `SIGINT` (see "`rproxy`"). - `smallwebwaf healthcheck` is the image's health check. With no command, or - one it does not know, the binary prints the commands and exits with - status 2. + `sww healthcheck` is the image's health check. With no command, or one it + does not know, the binary prints the commands and exits with status 2. - `internal/config`: reads the settings, the one place they are read. - - `internal/servewaf`: `servewaf`'s server. It runs the checks in `check`, - and holds `servewaf`'s question, answer and report. It answers - `servewaf`'s own endpoints in `answerOwnEndpoint`. It counts bytes, - refusals and anomalies, and writes the log lines. + - `internal/discriminator`: `discriminator`'s server. It runs the checks in + `check`, and holds `discriminator`'s question, answer and report. It + answers `discriminator`'s own endpoints in `answerOwnEndpoint`. It counts + bytes, refusals and anomalies, and writes the log lines. - `internal/rproxy`: the reverse proxy in front of one app that asks - `servewaf` about each request. It passes a request to the app with the - standard library's `httputil.ReverseProxy`, within the time and size + `discriminator` about each request. It passes a request to the app with + the standard library's `httputil.ReverseProxy`, within the time and size limits. - `internal/ratelimit`: the table of clients. It counts requests, bytes and refusals, and keeps each client's history. @@ -99,14 +98,15 @@ to them. - `internal/metrics`: the metrics, in the Prometheus text format. - `Dockerfile`: the lint and test phases, then the image. `deploy/example-app` is an app built on the image, with `rproxy` in its - container, which `script/example-app` checks with `servewaf` in another. + container, which `script/example-app` checks with `discriminator` in + another. - Libraries, checked against the owner's Go dependency defaults: - the standard library for the reverse proxy, HTTP clients, DNS, logging (`log/slog`) and the state files (`encoding/json`, `os.Rename`); - `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 `SWWAF_WAF_DISABLED_RULES`, are built for that version. They are + 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; - `github.com/hashicorp/golang-lru/v2` for the bounded tables; @@ -119,15 +119,15 @@ to them. ## What happens to a request -`rproxy` runs steps 1, 2 and 14, and sends the report of step 15. `servewaf` -runs steps 3 to 13, and does the counting of step 15. The first step that -produces a final answer ends the checks. Step 15 runs for each request -`servewaf` answers with `allow`, `deny` or `close`. `rproxy`'s size and time -limits apply to the whole exchange. Each step names the section that holds its -rules. +`rproxy` runs steps 1, 2 and 14, and sends the report of step 15. +`discriminator` runs steps 3 to 13, and does the counting of step 15. The first +step that produces a final answer ends the checks. Step 15 runs for each request +`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and +time limits apply to the whole exchange. Each step names the section that holds +its rules. -1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any - check, so a health checker never needs an exemption and is never refused. +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 @@ -136,29 +136,29 @@ rules. 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. - - `rproxy` then sends `servewaf` the question (see "The question"). - `servewaf` takes the address the question gives as the client. - - An IPv6 client is its group: the netblock of `SWWAF_IPV6_GROUP_PREFIX` + - `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` 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 static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12, the - byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused; a - client in both lists is let through. +3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte + limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client + in both lists is let through. 4. Bans. A client under a ban is refused (see "Bans"). -5. The lookup, unless `SWWAF_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 `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it: +5. 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: - a country list; - a biased threshold; - - `SWWAF_ADD_LOOKUP_HEADERS`. + - `SWW_ADD_LOOKUP_HEADERS`. A request that ends before the answer comes is logged without it. 6. The country lists (see "Country lists"). 7. The reputation sources (see "Reputation"). A client on a fetched blocklist - gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is + gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is banned. A client listed by a DNS blocklist, or scored at least - `SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`. + `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`. 8. The client's limit percentage (see "Biased thresholds"). 9. Rate limiting (see "Request rate limits, per client"). A request over a limit times the percentage is refused and bans the client. @@ -168,34 +168,33 @@ rules. refuses. 12. The header limits (see "Answering in time"), and then the Core Rule Set (see "Attack detection"). A Core Rule Set match is refused with `403` while - `SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is - `detect`. -13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees - that prefix. + `SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`. +13. A path under `/_sww/` is answered `404`, so that the app never sees that + prefix. 14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing - a request to the app"). With `SWWAF_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and + 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. 15. After the visitor's answer has ended, `rproxy` sends the report (see "The - report"). `servewaf` then counts: + report"). `discriminator` then counts: - For a request it allowed, the response bytes, the request bytes or both - are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a + 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 whose total passes a byte limit times the percentage has broken that limit and is banned. The response in progress is not cut off. - For a request it allowed, the client's history and the anomaly counters are updated, and the alert thresholds are evaluated. - For a request it refused, `servewaf` adds nothing to the byte limits. It - updates the history and the anomaly counters at its decision. It counts the - refusals the error burst counts at the decision too (see "Attack + For a request it refused, `discriminator` adds nothing to the byte limits. + It updates the history and the anomaly counters at its decision. It counts + the refusals the error burst counts at the decision too (see "Attack detection"). -In `SWWAF_MODE=observe`, every refusal above is logged as what would have -happened, nothing is refused, and `servewaf` makes no ban of its own. A path -under `/_smallwebwaf/` is still answered `404`, and `rproxy`'s size and time -limits still apply. +In `SWW_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. ## Counting method @@ -207,25 +206,25 @@ limits 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 - `SWWAF_MAX_TRACKED_CLIENTS`, the least recently seen client is dropped first, - with its history. The bans are bounded by `SWWAF_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_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. ## Bans Until a ban ends, every request from its netblock is refused with -`SWWAF_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to -`SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one +`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 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. -An offence is a request `servewaf` holds against the client. It is one of: +An offence is a request `discriminator` holds against the client. It is one of: - a clear sign of attack; - a broken limit; @@ -235,9 +234,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 `SWWAF_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 `SWWAF_ATTACK_BAN_DURATION` (default + 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 `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 @@ -247,16 +246,16 @@ to a ban, each by its own rule: - A broken limit, one of: - a request over a rate limit; - a response that takes the client's byte total past a byte limit; - - more than `SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute. + - more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute. The bans for a broken limit: - - The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with - the cause `limit`. So does one that comes more than - `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban + - 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 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 `SWWAF_MAX_BAN_DURATION` (default `7d`) is + - 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. - 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 @@ -278,43 +277,42 @@ fetched again. These refuse each request they cover, record nothing in `bans.json`, and count no offence: -- `SWWAF_DENY_NETS`; +- `SWW_DENY_NETS`; - a blocklist or reputation hit whose action is `deny`; - the country lists. Every ban carries notes with what an admin needs to decide whether to lift it, -drawn from what `servewaf` already knows. No lookup is made for the notes alone. -`docs/state-files.md` lists the notes with the rest of a ban's entry. +drawn from what `discriminator` already knows. No lookup is made for the notes +alone. `docs/state-files.md` lists the notes with the rest of a ban's entry. An admin adds, keeps or lifts a ban by editing `bans.json` (see "Persistent state") or through the ban endpoints (see "Admin endpoints"). A ban an admin -makes has the cause `admin`; an admin who wants to keep a ban `servewaf` made -sets its cause to `admin`. A ban lifted before it ends is kept, marked as +makes has the cause `admin`; an admin who wants to keep a ban `discriminator` +made sets its cause to `admin`. A ban lifted before it ends is kept, marked as lifted, and does not count toward a longer ban; it refuses nothing. Deleting its entry from `bans.json` forgets it entirely. A permanent ban does not run out. It ends when an admin lifts it, or when -`servewaf` drops it from a full table, as below. When `servewaf` holds -`SWWAF_MAX_BANS` bans of its own, it drops the earliest ban of one netblock: the +`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 `SWWAF_MAX_BANS`, so such bans can never fill the -table. +and does not count toward `SWW_MAX_BANS`, so such bans can never fill the table. ## Settings `docs/settings.md` lists every setting with its default and the values it takes, in the groups below. It also gives the form each kind of value takes. The groups -below are `servewaf`'s settings; "`rproxy`" gives the use of `rproxy`'s. +below are `discriminator`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions: - These settings take `off`, which switches them off: - - `SWWAF_LOOKUP_SOURCE` and `SWWAF_WAF_MODE`; + - `SWW_LOOKUP_SOURCE` and `SWW_WAF_MODE`; - the three rate limits and the three byte limits; - - `SWWAF_WAF_ANOMALY_THRESHOLD`, `SWWAF_WAF_BODY_LIMIT` and - `SWWAF_ERROR_BURST_THRESHOLD`; - - `SWWAF_ALERT_COOLDOWN` and `SWWAF_ALERT_MAX_PER_HOUR`; + - `SWW_WAF_ANOMALY_THRESHOLD`, `SWW_WAF_BODY_LIMIT` and + `SWW_ERROR_BURST_THRESHOLD`; + - `SWW_ALERT_COOLDOWN` and `SWW_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`. @@ -322,17 +320,16 @@ Conventions: 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 `SWWAF_` setting can be read from a file instead, for secrets and long - lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_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. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and - `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already hold a file name, so they have - no `_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches - disk. +- 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. No `SWRP_` setting has one, since `rproxy` never touches disk. - Settings, files included, are read once at start. Changing one means - restarting the container. The files `servewaf` watches while it runs are its - state files, its rule files and the lookup database. + restarting the container. The files `discriminator` watches while it runs are + its state files, its rule files and the lookup database. - An invalid setting stops the process at start with one log line, marked `"type":"process"` with `"msg":"invalid setting"`. The line names the variable and what is wrong with the value; "Each container's processes" says what @@ -342,60 +339,59 @@ Conventions: ### Core -- `SWWAF_INSTANCE_NAME` is in each line `servewaf` 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. -- `SWWAF_MODE=observe` lets a configuration be tried before it is enforced (see +- `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"). -- `SWWAF_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6 - client starts afresh; existing bans keep refusing their netblocks until they - end. `SWWAF_MAX_TRACKED_CLIENTS` and `SWWAF_MAX_BANS` set the bounds under - "Counting method" and "Bans". +- `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". ### Tokens -- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and - `/_smallwebwaf/clients/`. The metrics are off until `SWWAF_METRICS_TOKEN` - is set, since without a token they would be open to anything on `servewaf`'s - network. The two are separate, so the metrics collector, which holds the - metrics token, cannot manage bans. +- `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. - 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 -- `SWWAF_STATE_DIR` names the directory of the state files (see "State" under +- `SWW_STATE_DIR` names the directory of the state files (see "State" under "Deployment"). -- `bans.json` is written `SWWAF_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 `SWWAF_STATE_COUNTER_INTERVAL`, and at an orderly stop. +- `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. ### Logging - The request log on stdout is always on and has no switch (see "Request log"). - `SWWAF_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. -- `SWWAF_LOG_REQUEST_HEADERS` names the request headers to record. + `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. `Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if listed. The log says only whether `Authorization` and `Cookie` were present. -- `SWWAF_LOG_REMOTE_URL` names a syslog server (see "Request log"). +- `SWW_LOG_REMOTE_URL` names a syslog server (see "Request log"). ### Metrics -- `SWWAF_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 +- `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. ### Static lists -- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the - owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and - byte limits only; the error burst, attack detection and bans still apply. +- `SWW_ALLOW_NETS` skips the checks step 3 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. - 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 `SWWAF_ALLOW_NETS` or - `SWWAF_RATE_LIMIT_EXEMPT_NETS`. + shared address busier than the limits allow belongs in `SWW_ALLOW_NETS` or + `SWW_RATE_LIMIT_EXEMPT_NETS`. ### Request rate limits, per client @@ -423,16 +419,16 @@ Some legitimate clients pass the limits: - Everyone behind one shared address, such as an office, shares one client's limits. -Their addresses belong in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. +Their addresses belong in `SWW_RATE_LIMIT_EXEMPT_NETS`. -`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as +`SWW_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. `SWWAF_WAF_EXEMPT_PATHS` is -matched the same way. +app may read it as a path outside the prefix. `SWW_WAF_EXEMPT_PATHS` is matched +the same way. ### Byte limits, per client @@ -448,13 +444,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 `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other + 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). - 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 `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`; + - one in `SWW_ALLOW_NETS` or `SWW_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. @@ -467,7 +463,7 @@ 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. -- `SWWAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's +- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's first answer, when a setting needs it (see step 5). 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 @@ -477,14 +473,14 @@ service that keeps its visitors' addresses on its own host. else treats it differently. - GeoJS publishes no rate limit, but its terms forbid "an excessive amount of API requests", judged by GeoJS alone, and let it block a caller. While GeoJS - is slow, down or refusing `servewaf`, new clients count as unknown. GeoJS is - then left alone for a second, and twice as long after each further failure, up - to five minutes. Each failure raises a `source_failure` alert (see + is slow, down or refusing `discriminator`, new clients count as unknown. GeoJS + is then left alone for a second, and twice as long after each further failure, + up to five minutes. Each failure raises a `source_failure` alert (see "Alerting"). A service that cannot accept this uses the file. - The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`, `as_name` and `country_code` fields are read. The operator downloads it with a - free IPinfo account and mounts it read-only; `servewaf` never fetches it and - holds no account or token for it. IPinfo updates it daily. Its licence is + free IPinfo account and mounts it read-only; `discriminator` never fetches it + and holds no account or token for it. IPinfo updates it daily. Its licence is Creative Commons Attribution-ShareAlike 4.0, and IPinfo asks for a link to it on the site that uses it, such as "IP address data is powered by IPinfo". - The file is read whole at start; one that is missing or unreadable stops the @@ -498,21 +494,21 @@ service that keeps its visitors' addresses on its own host. Both are empty by default: the defaults judge a client by what it does, not by where it comes from. -- `SWWAF_DENIED_COUNTRIES` refuses every request from a listed country. -- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a +- `SWW_DENIED_COUNTRIES` refuses every request from a listed country. +- `SWW_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 - `SWWAF_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. -- Both may be set. `SWWAF_DENIED_COUNTRIES` then adds nothing, since the - exclusive list already refuses every other country. A code on both lists stops - the start with a message naming it. -- A refused request is answered with `SWWAF_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_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. +- Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive + list already refuses every other country. A code on both lists 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. ### Biased thresholds @@ -523,21 +519,21 @@ service's abuse comes from. - Each list gives AS numbers or countries a percentage, such as `AS14061:50`. Their clients get that percentage of every request and byte limit - (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A lowered limit is + (`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is rounded down. `0` is a zero allowance: the client's first request breaks a limit and bans it. -- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_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 percentage from - `SWWAF_ASN_LIMIT_PERCENT` and `SWWAF_ASN_LIMIT_PERCENT_URL`; for a country, - the one from `SWWAF_COUNTRY_LIMIT_PERCENT`. -- `SWWAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one - abuse-source list can be shared by every `servewaf` in the fleet. It is +- `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 percentage from `SWW_ASN_LIMIT_PERCENT` and + `SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from + `SWW_COUNTRY_LIMIT_PERCENT`. +- `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"). Where it and - `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. + `SWW_ASN_LIMIT_PERCENT` 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, or a reputation hit's `limit:`. A client - without a country gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of a country's. + without a country gets `SWW_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 notes of each ban, @@ -558,8 +554,8 @@ 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 `SWWAF_WAF_EXEMPT_PATHS`. -- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule +- 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 @@ -569,7 +565,7 @@ files, the limits and the bans still apply to the client that sends it. 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 `waf_block` alert is sent. -- `SWWAF_WAF_DISABLED_RULES` switches off rule ids when an app trips a false +- `SWW_WAF_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; @@ -584,29 +580,28 @@ 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. -- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as +- `SWW_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 `SWWAF_WAF_DISABLED_RULES` switches it off everywhere. The file + 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. - - `servewaf` loads the file after `smallwebwaf`'s own setup rules and before + - `discriminator` loads the file after `sww`'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 `SWWAF_WAF_DISABLED_RULES` switches off and the file names is + - A rule that `SWW_WAF_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 - `SWWAF_WAF_MODE` is. + `SWW_WAF_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. + of `sww`'s own rules, alone or in a range. Comment lines are not checked. - It stops too on 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. -- `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes - at most `64K`, since `servewaf` gets no more of a body than that (see "The +- `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 @@ -614,14 +609,14 @@ files, the limits and the bans still apply to the client that sends it. 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. -- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for, +- `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 `/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/` or `/blog/wp-login.php`. -- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these +- `SWW_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these kinds, and of no other: - a rule file match; - a trap path; @@ -765,11 +760,11 @@ case, as Coraza matches them. ### Ban lengths, response and scope -`SWWAF_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. `SWWAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban -to the surrounding netblock. The four ban lengths are under "Bans". +`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". ### Reputation @@ -777,11 +772,11 @@ No source is on by default. A source acts on a list kept elsewhere; the defaults judge a client only by what it does here. AbuseIPDB and CrowdSec also need an account or an engine of the operator's own. The list best suited to be on by default, Spamhaus DROP, has terms, below, that forbid frequent fetching, and -Spamhaus may block an address that breaks them. Each `servewaf` fetches its own -copy, so several of them on one host, sharing one address, would often fetch too -close together. +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 (`SWWAF_BLOCKLIST_URLS`) are text files of addresses and netblocks, +- 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 `::ffff:192.0.2.0/120` is read as the IPv4 netblock it stands for. Spamhaus's @@ -791,10 +786,10 @@ 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 `SWWAF_BLOCKLIST_REFRESH` is never less than `1h`, and the last good copy + 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. -- Each list is fetched again `SWWAF_BLOCKLIST_REFRESH` after its last try, one +- Each list is fetched again `SWW_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`; @@ -806,25 +801,25 @@ 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. -- `SWWAF_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 (`SWWAF_CROWDSEC_LAPI_URL`) has its decision - list fetched every minute, with `SWWAF_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 `servewaf` depending on - CrowdSec. A fetch fails when: +- `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: - 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 (`SWWAF_DNSBL_ZONES`) is asked about a client's own address +- 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 @@ -835,53 +830,53 @@ close together. - an answer in `127.255.255.0/24`; - an answer outside `127.0.0.0/8`; - a resolver error; - - no answer within `SWWAF_REPUTATION_TIMEOUT`. + - no answer within `SWW_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. - `servewaf` shows the key as `********` wherever it names the zone. Only - `reputation.json` keeps the key. `SWWAF_DNSBL_RESOLVER` names a resolver, + `discriminator` shows the key as `********` wherever it names the zone. Only + `reputation.json` keeps the key. `SWW_DNSBL_RESOLVER` names a resolver, since public resolvers are refused by several list operators. -- AbuseIPDB (`SWWAF_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 - (`SWWAF_ABUSEIPDB_DAILY_BUDGET`, under the free tier's 1000 checks a day) is +- 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 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 `SWWAF_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_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 `SWWAF_REPUTATION_TIMEOUT`. -- `SWWAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is `limit:25` + - 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 `SWWAF_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 +- 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 "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. - `SWWAF_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_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 `SWWAF_ALERT_COOLDOWN` before. An + or anomaly scope, as one sent less than `SWW_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 `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and +- 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 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. @@ -900,32 +895,32 @@ 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 -`SWWAF_ASN_LIMIT_PERCENT` for its AS number is the response. +`SWW_ASN_LIMIT_PERCENT` for its AS number is the response. - Requests and bytes 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 `SWWAF_WATCH_NETS`. -- Every request `servewaf` 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 `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS`, + - a named netblock in `SWW_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`. ## Rule files -`servewaf` reads every `*.rules` file in `SWWAF_RULES_DIR` and checks each +`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. - 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. `servewaf` watches - the directory: a file edited, added or removed there takes effect while it - runs. It reads the files again once the directory has had no change for 2 - seconds, so that a file still being written is read whole. A file whose name - starts with `.`, such as an editor's lock file, is not a rule file. + pattern seen in the request log becomes a rule in one line. `discriminator` + watches the directory: a file edited, added or removed there takes effect + while it runs. It reads the files again once the directory has had no change + for 2 seconds, so that a file still being written is read whole. A file whose + name starts with `.`, such as an editor's lock file, is not a rule file. - Files are read in name order (`00-default.rules` before `50-site.rules`), rules in line order. - A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in @@ -947,7 +942,7 @@ and the default file. - a regex that does not compile; - a duplicate id. - So does a `SWWAF_RULES_DIR` that does not exist or cannot be read. While + So does a `SWW_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 @@ -959,20 +954,20 @@ 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 `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces the + that `discriminator`. A directory mounted over `SWW_RULES_DIR` replaces the default file; single files mounted into it add to it. -- Rules meant to be edited while `servewaf` 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 `SWWAF_RULES_ENABLED=false`. +- 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`. ## Persistent state -All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of it, +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 -admin can read or edit them at any time, and the running `servewaf` takes the -edit in. `docs/state-files.md` lists the files, what each holds, and their +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. - Each file holds one kind of state, as JSON with: @@ -993,8 +988,8 @@ sizes. held up. It goes to a temporary file in the same directory, which is synced and renamed over the real name, and the directory is synced. A crash at any point leaves either the old complete file or the new complete file, never a - partial one. Before writing a file, `servewaf` takes in any edit made to it on - disk, so an admin's edit is never overwritten. + partial one. Before writing a file, `discriminator` takes in any edit made to + it on disk, so an admin's edit is never overwritten. - At start, the files are read back. Active bans go into an in-memory lookup by netblock, everything else into maps. Expired cache entries are discarded. A missing file means empty state, and is normal on first run. These stop the @@ -1008,38 +1003,38 @@ sizes. Since writes are atomic, a broken file can only come from a hand edit, which the editor should hear about. -- While running, `servewaf` notices a file that is edited, replaced or added. It - tells its own writes from an admin's by comparing the file with what it last - read or wrote. An edit that parses is taken in at once: what the file says - replaces what `servewaf` held for that file. An edit to `bans.json` makes, - lifts or forgets bans as "Bans" describes. A change `servewaf` made after the - admin opened the file, such as a new ban, is lost when the admin saves over - it. Most editors warn when a file changed on disk while it was open. A file - that is removed is written again at its next write. -- An edit that does not parse never stops the running `servewaf`. It keeps the - state it has and sets the edited file aside as `.bad`, so the edit is - kept. It writes the file again from memory, and names the file and the +- While running, `discriminator` notices a file that is edited, replaced or + added. It tells its own writes from an admin's by comparing the file with what + it last read or wrote. An edit that parses is taken in at once: what the file + says replaces what `discriminator` held for that file. An edit to `bans.json` + makes, lifts or forgets bans as "Bans" describes. A change `discriminator` + made after the admin opened the file, such as a new ban, is lost when the + admin saves over it. Most editors warn when a file changed on disk while it + was open. A file that is removed is written again at its next write. +- An edit that does not parse never stops the running `discriminator`. It keeps + the state it has and sets the edited file aside as `.bad`, so the edit + is kept. It writes the file again from memory, and names the file and the position of the error in the log and in one `file_error` alert. The admin mends the `.bad` file and moves it back. - A write that fails leaves the state correct in memory. It is logged, counted 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 - `SWWAF_STATE_WRITE_DELAY`, everything else from the last - `SWWAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not + `SWW_STATE_WRITE_DELAY`, everything else from the last + `SWW_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not service. ## Request log -`rproxy` and `servewaf` each write request lines on stdout, one JSON object to a -line, refused requests included. "The log" says which lines a request gets, and -`docs/request-log.md` lists the fields. Bodies are never logged, and no header -but those listed there. Query strings are logged as received; an app that +`rproxy` and `discriminator` each write request lines on stdout, one JSON object +to a line, refused requests included. "The log" says which lines a request gets, +and `docs/request-log.md` lists the fields. Bodies are never logged, and no +header but those listed there. Query strings are logged as received; an app that 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 `SWWAF_LOG_REMOTE_URL` is set, every line `servewaf` writes on stdout is +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. @@ -1048,36 +1043,35 @@ lines to stdout only. 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 `SWWAF_LOG_REMOTE_FACILITY`, the severity +- The record carries the facility from `SWW_LOG_REMOTE_FACILITY`, the severity informational, the time, the host's name and the app name from - `SWWAF_LOG_REMOTE_APP_NAME`. -- Sending happens on its own goroutine from a buffer of - `SWWAF_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 `servewaf` stops, it spends at most two seconds sending the lines still - waiting, and drops the rest; stdout has carried them. + `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. +- 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 /_smallwebwaf/metrics`, at `servewaf`'s own address, answers in the -Prometheus text format, for a request carrying `SWWAF_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 /_smallwebwaf/clients/`. +`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 +questions are answered by the request log and `GET /_sww/clients/`. ## Admin endpoints -`servewaf`'s own endpoints are paths under `/_smallwebwaf/`, at its own address -on the private network. Admins and the metrics collector reach them there, and +`discriminator`'s own endpoints are paths under `/_sww/`, at its own address on +the private network. Admins and the metrics collector reach them there, and nothing routes the internet to that address. The prefix carries the tool's name, so it takes no path an app uses. `docs/admin-endpoints.md` lists the endpoints, what each takes and what each answers. -- `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a - token, while `servewaf` is running. -- `POST /_smallwebwaf/report` takes `rproxy`'s report, without a token (see "The +- `GET /_sww/healthz` answers `200` with `ok` to anyone, without a token, while + `discriminator` is running. +- `POST /_sww/report` takes `rproxy`'s report, without a token (see "The report"). - Every other endpoint needs its token, sent as `Authorization: Bearer ` (see "Tokens" under "Settings"). A missing or wrong token is answered `401`. @@ -1087,27 +1081,27 @@ what each takes and what each answers. history holds them. - A ban made through the endpoints starts at once. -`rproxy` answers `GET /_smallwebwaf/healthz` itself. It never passes a path -under the prefix to the app (see "Without an answer from `servewaf`"). +`rproxy` answers `GET /_sww/healthz` itself. It never passes a path under the +prefix to the app (see "Without an answer from `discriminator`"). -## `servewaf` +## `discriminator` -`smallwebwaf servewaf` runs the checks for `smallwebwaf rproxy`, a small reverse -proxy in front of each app. `rproxy` asks `servewaf` about each visitor's -request. `servewaf` answers `rproxy` and passes nothing to an app itself. One -`servewaf` serves many sites. +`sww discriminator` runs the checks for `sww rproxy`, a small reverse proxy in +front of each app. `rproxy` asks `discriminator` about each visitor's request. +`discriminator` answers `rproxy` and passes nothing to an app itself. One +`discriminator` serves many sites. This section is the agreement between the two. The header names, the fields and the statuses below are exact. -`servewaf` reads the settings, the rule files, the lookup database and the state -files, and listens at `SWWAF_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it stops +`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. ### The question -A request carrying `X-Smallwebwaf-Client` is `rproxy` asking about one visitor's +A request carrying `X-Sww-Client` is `rproxy` asking about one visitor's request. It is the visitor's request as the visitor sent it: - the method; @@ -1134,112 +1128,110 @@ comes with a `Content-Length` of the part it carries. `rproxy` sets these headers, replacing any the visitor sent, and adds no others: -- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out, - without any IPv6 zone. `servewaf` takes it as the client, and groups an IPv6 +- `X-Sww-Client`: the visitor's address, which `rproxy` has worked out, without + any IPv6 zone. `discriminator` takes it as the client, and groups an IPv6 address as "What happens to a request" says. -- `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log - line's `peer_ip`. -- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is - absent when the visitor announced none. +- `X-Sww-Peer`: the address of `rproxy`'s own TCP peer, for the log line's + `peer_ip`. +- `X-Sww-Content-Length`: the body length the visitor announced. It is absent + when the visitor announced none. - `X-Request-ID`: the request's id, which `rproxy` makes fresh for every request - when it arrives. `servewaf` takes it as given and never makes one. + when it arrives. `discriminator` takes it as given and never makes one. - `X-Forwarded-Proto`: the scheme. -Before any check, `servewaf` removes the three `X-Smallwebwaf-` headers. It also +Before any check, `discriminator` removes the three `X-Sww-` headers. It also sets `Content-Length` to the length the visitor announced, or removes it. So the rule files and the Core Rule Set see the headers the visitor sent, with `rproxy`'s `X-Request-ID` and `X-Forwarded-Proto`. -`servewaf` refuses a question with `400` when: +`discriminator` refuses a question with `400` when: -- `X-Smallwebwaf-Client` is not an address, or is an address with a zone, such - as `2001:db8::1%eth0`; +- `X-Sww-Client` is not an address, or is an address with a zone, such as + `2001:db8::1%eth0`; - `X-Request-ID` is absent, empty, over 128 bytes or not valid UTF-8; -- `X-Request-ID` is the id of a question `servewaf` is still handling, or of a - request it holds (see "The report"); +- `X-Request-ID` is the id of a question `discriminator` is still handling, or + of a request it holds (see "The report"); - the body is over 64 KiB. -For a refused question, `servewaf` writes a process warning. It runs no check, -writes no request log line and counts nothing in the metrics. +For a refused question, `discriminator` writes a process warning. It runs no +check, writes no request log line and counts nothing in the metrics. These do not refuse a question: -- `X-Smallwebwaf-Peer` absent or not an address: the log line's `peer_ip` is +- `X-Sww-Peer` absent or not an address: the log line's `peer_ip` is `invalid IP`. -- `X-Smallwebwaf-Content-Length` not a whole number, or negative: it counts as - absent. +- `X-Sww-Content-Length` not a whole number, or negative: it counts as absent. - `X-Forwarded-Proto` absent: the log line's `scheme` is `http`. -- One of the five headers above sent more than once: `servewaf` uses its first - value. +- One of the five headers above sent more than once: `discriminator` uses its + first value. -`servewaf` reads the whole body before any check. A question whose body stops -arriving gets no answer. `servewaf` closes the connection once the 60 seconds -below run out, or as soon as `rproxy` closes its side. It writes a process -warning, runs no check, writes no request log line and counts nothing in the -metrics. +`discriminator` reads the whole body before any check. A question whose body +stops arriving gets no answer. `discriminator` closes the connection once the 60 +seconds below run out, or as soon as `rproxy` closes its side. It writes a +process warning, runs no check, writes no request log line and counts nothing in +the metrics. ### The answer -`servewaf` runs steps 3 to 13 of "What happens to a request", in that order. -Every answer but the last below carries `X-Smallwebwaf-Disposition`. +`discriminator` runs steps 3 to 13 of "What happens to a request", in that +order. Every answer but the last below carries `X-Sww-Disposition`. -- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the - request to the app. With `SWWAF_ADD_LOOKUP_HEADERS` the answer also carries +- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request + to the app. With `SWW_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-Smallwebwaf-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: - - `SWWAF_BAN_RESPONSE`, for a refusal; +- `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; - `403`, for a `block` rule or the Core Rule Set; - `431`, for a request over the header limits; - - `404`, for a path under `/_smallwebwaf/` that passes the checks, so that - the app never sees that prefix. -- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the - place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the - visitor's connection without an answer. -- No such header, status `400`, for a question `servewaf` refuses. `rproxy` + - `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 + 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 - without a valid `X-Smallwebwaf-Disposition`. + without a valid `X-Sww-Disposition`. In `observe` mode a request that `enforce` mode would refuse is answered -`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is -still answered `404`. +`allow`, and its log line has `would_action`. A path under `/_sww/` is still +answered `404`. The Core Rule Set inspects the body a question carries only while -`SWWAF_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it +`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. ### Answering in time -`rproxy` stops waiting for `servewaf` after `SWRP_TIMEOUT_MS`, by default 2 +`rproxy` stops waiting for `discriminator` after `SWRP_TIMEOUT_MS`, by default 2 seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults, -`servewaf` answers a request well within that time, whatever the request and -whatever its outside sources do. +`discriminator` answers a request well within that time, whatever the request +and whatever its outside sources do. GeoJS is the only outside source a question can wait on. A question waits only -when step 5 says so, and for at most `SWWAF_LOOKUP_TIMEOUT`, by default 800 +when step 5 says so, and for at most `SWW_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 CrowdSec decision list and the file - `SWWAF_ASN_LIMIT_PERCENT_URL` names. + `SWW_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 `SWWAF_BLOCKLIST_ACTION` is - `deny`; +- from a client on a fetched blocklist, while `SWW_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 -`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first. -`SWWAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting +`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 never lowers its limit. The static lists and bans come before the lookup, so they never wait. These can @@ -1247,13 +1239,13 @@ 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 `SWWAF_REPUTATION_ACTION` is `deny`; +- a DNS blocklist or AbuseIPDB, while `SWW_REPUTATION_ACTION` is `deny`; - a trap path; - a rule file; - the Core Rule Set. -The header limits are fixed, with no setting. `servewaf` refuses a request over -either one: +The header limits are fixed, with no setting. `discriminator` refuses a request +over either one: - more than 50 cookies. Each part of its `Cookie` headers between semicolons counts as one, unless it is empty or only spaces and tabs. @@ -1265,28 +1257,28 @@ log line's action is `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 `SWWAF_WAF_MODE`. A client in -`SWWAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode +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 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 `servewaf` past `SWRP_TIMEOUT_MS`. +such requests at once can overload `discriminator` past `SWRP_TIMEOUT_MS`. ### The report -Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report` -for each request `servewaf` answered with `allow`, `deny` or `close`. It sends -none after: +Once the visitor's answer has ended, `rproxy` sends `POST /_sww/report` for each +request `discriminator` answered with `allow`, `deny` or `close`. It sends none +after: -- an answer without a valid `X-Smallwebwaf-Disposition`, such as a `400`; -- an answer it cannot pass on (see "Without an answer from `servewaf`"); +- an answer without a valid `X-Sww-Disposition`, such as a `400`; +- an answer it cannot pass on (see "Without an answer from `discriminator`"); - no answer. -The report carries no `X-Smallwebwaf-Client`. Its body is one JSON object with -all of these fields, named as the request log's fields of the same meaning: +The report carries no `X-Sww-Client`. Its body is one JSON object with all of +these fields, named as the request log's fields of the same meaning: - `request_id`: the question's `X-Request-ID`; - `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`; @@ -1299,7 +1291,7 @@ all of these fields, named as the request log's fields of the same meaning: end of the answer, in the same range, and `0` for a request not passed to the app. -`servewaf` answers `400` and writes a process warning when the body: +`discriminator` answers `400` and writes a process warning when the body: - is not one JSON object, such as `null`, or an object followed by other data; - lacks one of these fields, or gives it as `null`; @@ -1310,11 +1302,11 @@ It answers `204` to any other report, also for a request it does not hold, which it ignores. It ignores a field not listed here. `rproxy` does not act on the answer. -For a request it holds, `servewaf` writes the request's last log line (see "The -log"). For a request it allowed, it first: +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 bytes as `SWWAF_BYTES_COUNT` says, toward the client's byte limits - at the percentages it gave that request; +- counts the bytes as `SWW_BYTES_COUNT` says, toward the client's byte limits at + the percentages it gave that request; - bans a client that passes a byte limit; - adds the request with its status to the client's history, the anomaly counters and the metrics. @@ -1322,34 +1314,34 @@ log"). For a request it allowed, it first: A denied request's counting is finished at its decision, so its report counts nothing. -`servewaf` holds each answered request until its report comes, up to 10,000 +`discriminator` holds each answered request until its report comes, up to 10,000 requests. Past that, the request answered longest ago is dropped. A request whose report never comes gets no last log line. If it was allowed, it is in neither the client's history nor the metrics, and its bytes are not counted. -`servewaf` holds only the decision's log line and what counting needs. It never -holds the request or response objects, and no `Authorization` or `Cookie`. A -report reaches only what is held, so it can never reach a question still being -handled. `servewaf` ignores a report naming such a question. +`discriminator` holds only the decision's log line and what counting needs. It +never holds the request or response objects, and no `Authorization` or `Cookie`. +A report reaches only what is held, so it can never reach a question still being +handled. `discriminator` ignores a report naming such a question. ### The log -A request `servewaf` answers with `allow`, `deny` or `close` gets three request -log lines, joined by `request_id`: +A request `discriminator` answers with `allow`, `deny` or `close` gets three +request log lines, joined by `request_id`: - `rproxy`'s short line; -- `servewaf`'s line at its decision; -- `servewaf`'s line when the report comes, with everything known: the decision - line's fields and the report's together. +- `discriminator`'s line at its decision; +- `discriminator`'s line when the report comes, with everything known: the + decision line's fields and the report's together. -The repetition is intended. The field `servewaf_line` says which of `servewaf`'s -two lines a line is. A request `servewaf` is not asked about, such as the health -check, gets only `rproxy`'s line. A request without a report gets no third line. -`docs/request-log.md` gives the fields. +The repetition is intended. The field `discriminator_line` says which of +`discriminator`'s two lines a line is. A request `discriminator` is not asked +about, such as the health check, gets only `rproxy`'s line. A request without a +report gets no third line. `docs/request-log.md` gives the fields. -### `servewaf`'s own endpoints +### `discriminator`'s own endpoints -A request without `X-Smallwebwaf-Client` is for `servewaf` itself, at its own +A request without `X-Sww-Client` is for `discriminator` itself, at its own address on the private network. The internet must not be able to reach that address. Its endpoints are: @@ -1362,66 +1354,66 @@ Anything else gets `404`. No check runs on these requests, since their peers are private network can send one or ask a question. A request and its answer have 60 seconds together, from the request's headers. -`servewaf` closes a connection idle for 120 seconds. It takes a request line and -headers up to 1 MiB, Go's default, so that it never refuses a question for the -size of a visitor's headers. +`discriminator` closes a connection idle for 120 seconds. It takes a request +line and headers up to 1 MiB, Go's default, so that it never refuses a question +for the size of a visitor's headers. ### Sites -One `servewaf` keeps one table of clients, counters and bans for every site that -asks it. A client banned through one site is refused on all of them, and its -requests on all of them count toward one set of limits. Log lines, alerts raised -by a request and ban notes name the site by its host. +One `discriminator` keeps one table of clients, counters and bans for every site +that asks it. A client banned through one site is refused on all of them, and +its requests on all of them count toward one set of limits. Log lines, alerts +raised by a request and ban notes name the site by its host. `docs/metrics.md` says how its metrics count. ## `rproxy` -`smallwebwaf rproxy` is the reverse proxy in front of one app. It asks -`servewaf` about each request, and passes on what `servewaf` allows. It keeps -nothing between requests and never touches disk. When `servewaf` cannot be +`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 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. `rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of -them is required: `SWRP_WAF_BACKEND`, `servewaf`'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"). +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"). ### What `rproxy` does with a request -1. `GET /_smallwebwaf/healthz` is answered `ok` at once, without asking - `servewaf`. +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. -3. It makes a fresh request id, whatever id the request came with. `servewaf` - and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it. +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. 4. It refuses a request with a method it does not pass on, or one it cannot put - to `servewaf` as the visitor sent it (see "Requests `rproxy` refuses + to `discriminator` as the visitor sent it (see "Requests `rproxy` refuses itself"). 5. It reads up to the first 64 KiB of the body into memory. -6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to - `servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to - send a visitor's `Upgrade` header, and most `Connection` headers. The - question never carries the visitor's `Trailer`. +6. It sends `discriminator` the question (see "The question"). It speaks + HTTP/1.1 to `discriminator`, whatever `discriminator` offers. Go's HTTP/2 + client would refuse to send a visitor's `Upgrade` header, and most + `Connection` headers. The question never carries the visitor's `Trailer`. 7. It does what the answer says (see "The answer"): - `allow`: it passes the request to the app. - `close`: it closes the visitor's connection without an answer. - Any other answer: the visitor gets its status, headers and body, less - `X-Smallwebwaf-Disposition`. That holds for a `deny`, for an error such as - `servewaf`'s `400` or a `5xx`, and for an answer without a valid - `X-Smallwebwaf-Disposition`. + `X-Sww-Disposition`. That holds for a `deny`, for an error such as + `discriminator`'s `400` or a `5xx`, and for an answer without a valid + `X-Sww-Disposition`. 8. Once the answer has ended, it writes its log line. `docs/request-log.md` gives the fields. An app's answer can end just after the line: Go's server sends the closing chunk of a chunked answer, and may send all of an empty one, only once `rproxy` has finished with the request. -9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close` - (see "The report"). The visitor does not wait for it. A report that fails is - dropped, with a process warning. A status above `599` from the app goes to - the visitor as it is, and into the report as `599`, the highest `servewaf` - takes. +9. It then sends the report, if `discriminator` answered `allow`, `deny` or + `close` (see "The report"). The visitor does not wait for it. A report that + fails is dropped, with a process warning. A status above `599` from the app + goes to the visitor as it is, and into the report as `599`, the highest + `discriminator` takes. ### Requests `rproxy` refuses itself @@ -1458,10 +1450,10 @@ Go's HTTP server answers `OPTIONS *` itself, before `rproxy` sees it. goes away or stops its sending side. There is no question and no report, and the log line has status `0`. -### Without an answer from `servewaf` +### Without an answer from `discriminator` `SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when -`servewaf`: +`discriminator`: - cannot be connected to; - does not answer at all; @@ -1474,22 +1466,22 @@ An answer whose body breaks off counts as no answer at all. Either way `rproxy` writes a process warning, its log line has `default_disposition`, and it sends no report. -A visitor can make `servewaf` slow. A request that keeps `servewaf` busy past -`SWRP_TIMEOUT_MS`, like any request while `servewaf` is overloaded, gets -`SWRP_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead of -reaching the app. +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. -A path under `/_smallwebwaf/` that `allow` would pass on is answered `404` -instead, so that the app never sees that prefix. +A path under `/_sww/` that `allow` would pass on is answered `404` instead, so +that the app never sees that prefix. `rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy` writes a process warning, its log line has no `disposition`, and it sends no report. -A visitor that goes away while `servewaf` is being asked is no failure of -`servewaf`. `rproxy` closes the connection, with no warning, no default and no -report. Its log line has status `0` and no `disposition`. +A visitor that goes away while `discriminator` is being asked is no failure of +`discriminator`. `rproxy` closes the connection, with no warning, no default and +no report. Its log line has status `0` and no `disposition`. ### Passing a request to the app @@ -1506,9 +1498,9 @@ unchanged but for these headers: 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 `servewaf` answered. A - header it did not give is left out. Any of the two the visitor sent is - removed. +- `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 + sent is removed. - The headers that concern only the connection to `rproxy` never reach the app: - `Connection`, and any header it names; - `Keep-Alive`; @@ -1518,7 +1510,7 @@ unchanged but for these headers: - `Te`, though one holding `trailers` goes on as `Te: trailers`; - `Upgrade`. -A chunked body's trailers reach the app, but not `servewaf`. +A chunked body's trailers reach the app, but not `discriminator`. A WebSocket upgrade passes through, with `Connection: Upgrade` and its `Upgrade`. The report of a WebSocket comes when it closes, with what it carried @@ -1532,7 +1524,7 @@ These limits apply per request, in both directions: - `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 `servewaf` counts toward it. + 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 @@ -1551,12 +1543,12 @@ What the visitor gets when a limit is passed: the connection, nothing reaches the app, and the request log gets no line. - A visitor too slow to send the first 64 KiB of its body is answered `408`, with no question and no report. -- `rproxy` applies the request size limit only after `servewaf` has allowed the - request. So a request `servewaf` denies gets `servewaf`'s answer, whatever its - size. A request `servewaf` allows is answered `413` when its body is over the - limit: at once for a length it announced, otherwise when the limit is reached. - If part of the body has gone to the app by then, the request to the app is cut - off. +- `rproxy` applies the request size limit only after `discriminator` has allowed + the request. So a request `discriminator` denies gets `discriminator`'s + answer, whatever its size. A request `discriminator` allows is answered `413` + when its body is over the limit: at once for a length it announced, otherwise + when the limit is reached. If part of the body has gone to the app by then, + the request to the app is cut off. - A limit passed after that, but before the response has started, is answered by `rproxy`: - `408` for a visitor too slow to send the rest of its body; @@ -1584,9 +1576,9 @@ such as that of a WebSocket that closes later. ## Deployment -One image runs either command. `SMALLWEBWAF_COMMAND`, in a container's -environment, chooses: `servewaf` runs `servewaf`, and with it unset the image -runs `rproxy`. `docs/image.md` lists what the image holds and how each part is +One image runs either command. `SWW_COMMAND`, in a container's environment, +chooses: `discriminator` runs `discriminator`, and with it unset the image runs +`rproxy`. `docs/image.md` lists what the image holds and how each part is pinned. ### The usual setup @@ -1595,31 +1587,32 @@ 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 `servewaf`; -- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume for its state + `SWRP_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 `servewaf` (see "Sites"). Anything on that -network can reach an app around `rproxy`, and can ask `servewaf` questions or -send it reports. So the network holds only what you trust. +`SWRP_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 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. `servewaf` still -runs in a container of its own, and the app's container sets `SWRP_WAF_BACKEND`. +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`. Beyond its `FROM` line, the app's Dockerfile adds: - its binary; - any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.`; - its runit service: a directory under `/etc/service` named after the app, never - `smallwebwaf`. Its `run` script starts the app with `chpst`, listening where + `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 - `smallwebwaf`, so that the app cannot touch the process of `rproxy`. + `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. @@ -1631,9 +1624,10 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start moves to the next LTS release when that ships. Its packages come from Ubuntu's archive, whose packages change with every update. - The Ubuntu image has no CA certificates. Without them Nix cannot download - packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed - by name. apt fetches the packages over plain HTTP and checks each one against - the archive's signed package lists, so the install needs no CA certificates. + packages and `discriminator` cannot reach GeoJS, so `ca-certificates` is + installed by name. apt fetches the packages over plain HTTP and checks each + one against the archive's signed package lists, so the install needs no CA + certificates. - nixpkgs comes from a release file on `releases.nixos.org`, named with its hash, not from a GitHub archive, whose bytes can change. Root uses Nix directly; no Nix daemon runs. `nix-bin` creates no build users, so @@ -1651,13 +1645,13 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start writes. `runsvinit` starts runit's `runsvdir`, which runs the `run` script of each directory under `/etc/service`. Ubuntu's `sv` looks for services there too. -- The image's `run` script is `/etc/service/smallwebwaf/run`. It starts - `servewaf` when `SMALLWEBWAF_COMMAND` is `servewaf`, and `rproxy` when it is - `rproxy`, unset or empty. It runs either as the `smallwebwaf` user (uid and - gid 65532), with runit's `chpst`. For `servewaf`, it first makes - `SWWAF_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 `SMALLWEBWAF_COMMAND` and its value. +- 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. 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` @@ -1674,24 +1668,26 @@ process that stops at start is started again every second, not in a tight loop. - When a service exits, runit runs its `run` script again, and the container 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 `servewaf` is down, - `SWRP_DEFAULT_DISPOSITION` decides (see "Without an answer from `servewaf`"). -- Only faults found at start stop `servewaf`: + answers `502`, which is not a client offence. While `discriminator` is down, + `SWRP_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 `SWWAF_STATE_DIR`; - - a `SWWAF_RULES_DIR` that does not exist or cannot be read; - - a `SWWAF_LISTEN_ADDR` it cannot listen on. + - 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`. - 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 - `servewaf` down. + `discriminator` down. - `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop every service at once, each with `SIGTERM`, and exits once they have stopped. - `rproxy` and `servewaf` then stop as "`rproxy`" and "`servewaf`" say. + `rproxy` and `discriminator` then stop as "`rproxy`" and "`discriminator`" + say. - With `rproxy` beside the app, an app that exits at once on `SIGTERM` ends the requests in progress: the visitor gets `502`, or a response cut short. To let them finish, the app must keep answering for five seconds after `SIGTERM`. @@ -1700,19 +1696,17 @@ process that stops at start is started again every second, not in a tight loop. ### The health check -The image's `HEALTHCHECK` runs `smallwebwaf healthcheck`. `SMALLWEBWAF_COMMAND` -says what it checks: +The image's `HEALTHCHECK` runs `sww healthcheck`. `SWW_COMMAND` says what it +checks: -- With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers - `GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in - `SWWAF_LISTEN_ADDR`. -- With `SMALLWEBWAF_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 - `servewaf`, so that the site stays in traefik's routes while `servewaf` is - down. -- With any other value, it fails with a message naming `SMALLWEBWAF_COMMAND` and - its value. +- With `SWW_COMMAND=discriminator`, it passes while `discriminator` answers + `GET /_sww/healthz` on `127.0.0.1`, at the port in `SWW_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. +- With any other value, it fails with a message naming `SWW_COMMAND` and its + value. traefik sends a container no requests until it shows as healthy. So the check runs every second from the container's start until it first passes, for up to a @@ -1728,7 +1722,7 @@ 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. -`servewaf` listens on no port but the one in `SWWAF_LISTEN_ADDR`, 8080 by +`discriminator` listens on no port but the one in `SWW_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 @@ -1741,24 +1735,24 @@ the container. ### State -`servewaf` keeps its state files in `SWWAF_STATE_DIR`, belonging to the -`smallwebwaf` 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/smallwebwaf`, 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_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 (`SWWAF_ADMIN_TOKEN_FILE`, `SWWAF_METRICS_TOKEN_FILE`) -stays out of the container's environment. The operator makes the file on the -host, owned by uid 65532, the `smallwebwaf` user, with mode `0400`, and mounts -the directory that holds it into `servewaf`'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_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. ### Forwarded headers @@ -1781,9 +1775,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. `servewaf` 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. +`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. ## Planned, not built diff --git a/cmd/smallwebwaf/main.go b/cmd/smallwebwaf/main.go deleted file mode 100644 index c7d998c..0000000 --- a/cmd/smallwebwaf/main.go +++ /dev/null @@ -1,20 +0,0 @@ -// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is -// the reverse proxy in front of one app, which asks servewaf about each request. -// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts. -// `smallwebwaf healthcheck` is the container's health check. Everything it does -// is in internal/smallwebwaf. -package main - -import ( - "os" - - "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" -) - -// Version is the version of the binary, set when it is built with -// -ldflags "-X main.Version=...". -var Version = "dev" //nolint:gochecknoglobals // the linker sets it - -func main() { - os.Exit(smallwebwaf.Main(Version)) -} diff --git a/cmd/sww/main.go b/cmd/sww/main.go new file mode 100644 index 0000000..9453950 --- /dev/null +++ b/cmd/sww/main.go @@ -0,0 +1,20 @@ +// Command sww is a web application firewall. `sww rproxy` is +// the reverse proxy in front of one app, which asks discriminator about each request. +// `sww discriminator` decides, for many sites, and keeps what lasts. +// `sww healthcheck` is the container's health check. Everything it does +// is in internal/sww. +package main + +import ( + "os" + + "sneak.berlin/go/smallwebwaf/internal/sww" +) + +// Version is the version of the binary, set when it is built with +// -ldflags "-X main.Version=...". +var Version = "dev" //nolint:gochecknoglobals // the linker sets it + +func main() { + os.Exit(sww.Main(Version)) +} diff --git a/deploy/example-app/Dockerfile b/deploy/example-app/Dockerfile index a228c17..81b6066 100644 --- a/deploy/example-app/Dockerfile +++ b/deploy/example-app/Dockerfile @@ -6,8 +6,8 @@ # A real app names the smallwebwaf image by digest. This one takes the # image script/example-app has just built, or else the one `make docker` # builds. -ARG SMALLWEBWAF_IMAGE=smallwebwaf -FROM ${SMALLWEBWAF_IMAGE} +ARG SWW_IMAGE=smallwebwaf +FROM ${SWW_IMAGE} # Packages the app needs, from the nixpkgs in the image. RUN nix-env -iA nixpkgs.busybox diff --git a/docs/admin-endpoints.md b/docs/admin-endpoints.md index ddb82ea..c1f31da 100644 --- a/docs/admin-endpoints.md +++ b/docs/admin-endpoints.md @@ -1,60 +1,60 @@ # Admin endpoints -The endpoints under `/_smallwebwaf/`, what each takes and what each answers. -`smallwebwaf servewaf` answers them at its own address, on the private network. +The endpoints under `/_sww/`, what each takes and what each answers. +`sww discriminator` answers them at its own address, on the private network. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. -- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone. -- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see +- `GET /_sww/healthz`: `200` and `ok`, to anyone. +- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see [`metrics.md`](metrics.md)). -- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json` - lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below. -- `POST /_smallwebwaf/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 - (`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such +- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it. + Needs `SWW_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: - 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. -- `DELETE /_smallwebwaf/bans/`: lifts every active ban covering the - address ``. It answers `404` when none is active. -- `GET /_smallwebwaf/clients/`: what `servewaf` knows of the client. Under +- `DELETE /_sww/bans/`: lifts every active ban covering the address + ``. It answers `404` when none is active. +- `GET /_sww/clients/`: what `discriminator` knows of the client. Under `client`, its counters and history as `clients.json` holds them, or `null` when `clients.json` does not hold it. Under `bans`, every ban covering the address, with its notes. -- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It - answers `204`, or `400` to a body that is no report (see "The report" in +- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers + `204`, or `400` to a body that is no report (see "The report" in [`SPEC.md`](../SPEC.md)). No check runs on these requests. A missing or wrong token is answered `401`. An endpoint whose token is unset answers `404`, as does any other path. -`smallwebwaf rproxy` answers only the health check of these, at its own address. -It asks `servewaf` about any other request under `/_smallwebwaf/`. One that -passes the checks is answered `404`, so that the app never sees that prefix (see -"`rproxy`" in [`SPEC.md`](../SPEC.md)). +`sww rproxy` answers only the health check of these, at its own address. It asks +`discriminator` about any other request under `/_sww/`. One that passes the +checks is answered `404`, so that the app never sees that prefix (see "`rproxy`" +in [`SPEC.md`](../SPEC.md)). ## Examples -With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as -`servewaf:8080` on its network: +With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached +as `discriminator:8080` on its network: ```sh # Every ban. -curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans +curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans # Ban 203.0.113.0/24 for seven days. curl -H "Authorization: Bearer $TOKEN" \ --json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \ - http://servewaf:8080/_smallwebwaf/bans + http://discriminator:8080/_sww/bans # Lift the bans on 203.0.113.9. curl -H "Authorization: Bearer $TOKEN" -X DELETE \ - http://servewaf:8080/_smallwebwaf/bans/203.0.113.9 + http://discriminator:8080/_sww/bans/203.0.113.9 -# What servewaf knows of 203.0.113.9. +# What discriminator knows of 203.0.113.9. curl -H "Authorization: Bearer $TOKEN" \ - http://servewaf:8080/_smallwebwaf/clients/203.0.113.9 + http://discriminator:8080/_sww/clients/203.0.113.9 ``` diff --git a/docs/alerts.md b/docs/alerts.md index 093e0da..931d6d0 100644 --- a/docs/alerts.md +++ b/docs/alerts.md @@ -6,7 +6,7 @@ back or dropped. ## Events -- `ban`: a ban `servewaf` made. +- `ban`: a ban `discriminator` made. - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by a request. - `waf_block`: the Core Rule Set scored a request at or over the threshold. The @@ -34,7 +34,7 @@ on one line: ```json { - "instance": "fsn1/servewaf", + "instance": "fsn1/discriminator", "time": "2026-10-06T12:00:50.123461Z", "event": "ban", "host": "app.example", @@ -77,11 +77,11 @@ on one line: } ``` -- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: +- `instance`: `SWW_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 `servewaf` - serves many sites. It is left out of `source_failure`, `file_error` and - `summary`. +- `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`. - `client`: the address whose request raised the alert. Empty for `source_failure`, `file_error` and `summary`. - `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise @@ -111,8 +111,8 @@ on one line: ## Slack and ntfy Slack and ntfy get a message with a title and a text. The title is the instance -and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one -line for each of these that the alert has: +and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then +one line for each of these that the alert has: - the host, the client, the netblock and the country; - the file, the source, the error and the mode from `detail`; @@ -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 - `SWWAF_ALERT_NTFY_TOKEN` is set; +- `Authorization`: the token, as `Bearer `, while `SWW_ALERT_NTFY_TOKEN` + is set; - `Priority` and `Tags`: by event, as listed below. The priority and tag by event: diff --git a/docs/image.md b/docs/image.md index 2e99289..4f9bf09 100644 --- a/docs/image.md +++ b/docs/image.md @@ -1,7 +1,7 @@ # What the image holds The contents of the `smallwebwaf` image, and how each part is pinned. The image -runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`. +runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`. "Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it is pinned that way. @@ -15,13 +15,12 @@ is pinned that way. Unpacked, nixpkgs takes about 500 MiB. - `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit` at a fixed commit hash. -- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532). -- The service directory `/etc/service/smallwebwaf`, with the `run` script - `share/smallwebwaf.run`. -- `/var/lib/smallwebwaf` for `servewaf`'s state files, and - `/etc/smallwebwaf/rules.d` with the default rule file - `share/rules.d/00-default.rules`. -- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`. +- The `sww` binary, and the user `sww` (uid and gid 65532). +- The service directory `/etc/service/sww`, with the `run` script + `share/sww.run`. +- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with + the default rule file `share/rules.d/00-default.rules`. +- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`. ## Pins @@ -33,4 +32,4 @@ is pinned that way. - `runsvinit`: the archived repository, unchanged since 2015, at its last commit (`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the build writes. -- The Go image that builds `smallwebwaf`: by digest. +- The Go image that builds `sww`: by digest. diff --git a/docs/metrics.md b/docs/metrics.md index d6039be..ba99365 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -1,90 +1,81 @@ # The metrics -What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address. +What `GET /_sww/metrics` answers with, at `discriminator`'s own address. "Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance` label as `exported_instance` unless the scrape sets `honor_labels: true`. `rproxy` has no metrics. ## Traffic -- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and - `smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by - `action`; -- `smallwebwaf_request_duration_seconds` and - `smallwebwaf_upstream_duration_seconds`, as histograms; -- `smallwebwaf_requests_in_flight`: the questions and requests to its own - endpoints that `servewaf` is handling. +- `sww_requests_total`, `sww_request_bytes_total` and + `sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`; +- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as + histograms; +- `sww_requests_in_flight`: the questions and requests to its own endpoints that + `discriminator` is handling. ## Limits and bans -- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`, - `bytes` or `refusals`); -- `smallwebwaf_offences_total`, by `kind`; -- `smallwebwaf_bans_made_total`, by `cause`; -- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. +- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or + `refusals`); +- `sww_offences_total`, by `kind`; +- `sww_bans_made_total`, by `cause`; +- `sww_active_bans` and `sww_permanent_bans`. ## Attack detection -- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and - `smallwebwaf_rules_loaded`; -- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that - fired. +- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`; +- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired. ## Countries and AS numbers -For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`: +For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`: -- `smallwebwaf_country_requests_total`, - `smallwebwaf_country_request_bytes_total`, - `smallwebwaf_country_response_bytes_total` and - `smallwebwaf_country_list_refusals_total`, by `country`; -- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and - `smallwebwaf_asn_response_bytes_total`, by `asn`. +- `sww_country_requests_total`, `sww_country_request_bytes_total`, + `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by + `country`; +- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and + `sww_asn_response_bytes_total`, by `asn`. ## Lookups -- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`; -- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and - went on without it; -- with the file source, - `smallwebwaf_lookup_database_last_read_timestamp_seconds` and - `smallwebwaf_lookup_database_read_failures_total`. +- `sww_geojs_requests_total` and `sww_geojs_failures_total`; +- `sww_geojs_unanswered_total`: the requests that needed an answer and went on + without it; +- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and + `sww_lookup_database_read_failures_total`. ## Reputation sources By `source`: each list's URL, each zone, and `abuseipdb`: -- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total` - and `smallwebwaf_reputation_failures_total`; -- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list; -- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB. +- `sww_reputation_hits_total`, `sww_reputation_queries_total` and + `sww_reputation_failures_total`; +- `sww_reputation_last_fetch_timestamp_seconds`, for a list; +- `sww_reputation_daily_budget_remaining`, for AbuseIPDB. ## State files By `file`: -- `smallwebwaf_state_file_writes_total`, - `smallwebwaf_state_file_write_failures_total`, - `smallwebwaf_state_file_last_write_timestamp_seconds` and - `smallwebwaf_state_file_size_bytes`; -- `smallwebwaf_state_file_edits_taken_in_total` and - `smallwebwaf_state_file_edits_set_aside_total`; -- `smallwebwaf_tracked_clients`: the clients in the table. +- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`, + `sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`; +- `sww_state_file_edits_taken_in_total` and + `sww_state_file_edits_set_aside_total`; +- `sww_tracked_clients`: the clients in the table. ## Remote log, alerts and the process -- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`, - `smallwebwaf_remote_log_lines_dropped_total` and - `smallwebwaf_remote_log_buffer_depth`; -- by `destination`, `smallwebwaf_alerts_sent_total`, - `smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and - `smallwebwaf_alerts_dropped_total`; +- while a syslog server is set, `sww_remote_log_lines_sent_total`, + `sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`; +- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`, + `sww_alerts_suppressed_total` and `sww_alerts_dropped_total`; - Go's own `go_` metrics and the process's `process_` metrics. ## When a request is counted -`servewaf` counts a denied request at its decision, and a request to its own -endpoints when it is answered. It counts an allowed request when its report +`discriminator` counts a denied request at its decision, and a request to its +own endpoints when it is answered. It counts an allowed request when its report comes, with the status, bytes and times the report gives. These are not counted: - a report itself; diff --git a/docs/request-log.md b/docs/request-log.md index 0eb5411..5c20e18 100644 --- a/docs/request-log.md +++ b/docs/request-log.md @@ -1,32 +1,32 @@ # The request log's fields -The fields of the JSON objects `rproxy` and `servewaf` write about each request. -"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is -written and how the log is sent to a syslog server. A field that does not apply -to a request is left out. +The fields of the JSON objects `rproxy` and `discriminator` write about each +request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line +is written and how the log is sent to a syslog server. A field that does not +apply to a request is left out. -## `servewaf`'s lines +## `discriminator`'s lines -`smallwebwaf servewaf` writes two lines for each question it answers, joined by -`request_id`. The field `servewaf_line` says which of the two a line is: +`sww discriminator` writes two lines for each question it answers, joined by +`request_id`. The field `discriminator_line` says which of the two a line is: - `decision`: the line it writes once it has answered the question; - `report`: the line it writes when `rproxy`'s report comes, with everything known. The repetition is intended. -A request to its own endpoints writes one line, without `servewaf_line`. A +A request to its own endpoints writes one line, without `discriminator_line`. A report writes no line but the `report` line of the request it names. These questions write no line: -- a question refused with `400` (see "The question" under "`servewaf`" in +- a question refused with `400` (see "The question" under "`discriminator`" in [`SPEC.md`](../SPEC.md)); - a question whose body stopped arriving, which gets no answer. A `report` line: ``` -{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104} +{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104} ``` ### Every line @@ -41,10 +41,10 @@ Every one of these lines has: ### Standard web log fields -- `time`: when `servewaf` had read the question's body, RFC 3339 with +- `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`: `SWWAF_INSTANCE_NAME`. +- `instance`: `SWW_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 @@ -52,8 +52,9 @@ Every one of these lines has: - `host`: the site the visitor asked for. - `protocol`: the question's own, `HTTP/1.1`. - `status`: - - on the `decision` line, what `servewaf` answered: `200` for `allow`, the - status of `deny`, or `0` for `close`, as the visitor is then sent nothing; + - on the `decision` line, what `discriminator` answered: `200` for `allow`, + the status of `deny`, or `0` for `close`, as the visitor is then sent + nothing; - on the `report` line, what the visitor got, `0` if nothing. - `request_bytes`: - on the `decision` line, how much of the question's body the Core Rule Set @@ -61,7 +62,7 @@ Every one of these lines has: - on the `report` line, the bytes of the visitor's body that `rproxy` read, with what a WebSocket carried from the visitor. - `response_bytes`: - - on the `decision` line, the body of `servewaf`'s answer; + - on the `decision` line, the body of `discriminator`'s answer; - on the `report` line, the bytes of the answer's body, with what a WebSocket carried from the app. - `referer` and `user_agent`. @@ -78,32 +79,32 @@ Every one of these lines has: - lookups are off; - the client is not looked up; - the source cannot place the client; - - GeoJS had not answered by the time `servewaf` answered. + - 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 `SWWAF_LOG_REQUEST_HEADERS`, by name - in lower case. +- `request_headers`: the headers listed in `SWW_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. ### Response detail -- `response_content_type`: the type of `servewaf`'s own answer. +- `response_content_type`: the type of `discriminator`'s own answer. - `aborted`: true when `rproxy` went away before the answer. ### Decision - `action`: what was done: - `forward`: answered `allow`, so that `rproxy` passes it to the app; - - `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with - the action `deny`; + - `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the + action `deny`; - `banned`: refused under a ban, or for a `ban` rule, a trap path or a CrowdSec decision, each of which bans the client; - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: refused by that check; - `too_large`: over a header limit; - - `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a - path under `/_smallwebwaf/`. + - `admin`: answered at one of `discriminator`'s own endpoints, or `404` for + a path under `/_sww/`. - `would_action`: in `observe` mode, what `enforce` mode would have done with a request it would have refused. `action` then names what was done, normally `forward`. @@ -147,8 +148,8 @@ Every one of these lines has: All in milliseconds: - `duration_total`: - - on the `decision` line, from `servewaf` having read the question's body to - the line; + - on the `decision` line, from `discriminator` having read the question's + body to the line; - on the `report` line, from `rproxy` having read the request's headers to the end of the answer. - `duration_checks`: to the end of the checks; @@ -158,8 +159,8 @@ All in milliseconds: ### Requests to its own endpoints -The line of a request to `servewaf`'s own endpoints has these fields, with these -meanings where they differ: +The line of a request to `discriminator`'s own endpoints has these fields, with +these meanings where they differ: - `client_ip` and `peer_ip`: the request's TCP peer. - `request_id`: a new random one of 26 letters and digits. @@ -171,8 +172,8 @@ meanings where they differ: ## `rproxy`'s line -`smallwebwaf rproxy` writes one short line for each request. `request_id` joins -it to `servewaf`'s two. It has these fields and no others: +`sww rproxy` writes one short line for each request. `request_id` joins it to +`discriminator`'s two. It has these fields and no others: - `type`: `request`. - `time`: when the request arrived, RFC 3339 with milliseconds, in UTC. @@ -187,15 +188,15 @@ it to `servewaf`'s two. It has these fields and no others: carried from the app. - `duration_request`: milliseconds from the request's headers being read until the app had been sent the whole request, or the answer started if that was - sooner. It includes the wait for `servewaf`. + sooner. It includes the wait for `discriminator`. - `duration_response`: milliseconds from then until the end of the answer. - `disposition`: what was done with the request: `allow`, `deny` or `close`. It - is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left - out when: - - `servewaf` was not asked, as for the health check; - - the visitor went away while `servewaf` was being asked; - - `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`; - - `rproxy` could not pass on `servewaf`'s answer, and answered `502`. + is `discriminator`'s answer, or the value of `SWRP_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 `disposition`, and otherwise left out. diff --git a/docs/rules.md b/docs/rules.md index 987f563..cfa69e7 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b ## The default file The image ships one file, `share/rules.d/00-default.rules`, installed as -`/etc/smallwebwaf/rules.d/00-default.rules`: +`/etc/sww/rules.d/00-default.rules`: ``` # 00-default.rules: probes no real visitor sends, anchored at the site root @@ -54,19 +54,20 @@ empty-agent user_agent log ^$ ## A file of your own -`servewaf` reads the rule files in its own container. Rules of your own go in a -file named to sort after the default, such as `50-site.rules`: +`discriminator` reads the rule files in its own container. Rules of your own go +in a file named to sort after the default, such as `50-site.rules`: ``` # 50-site.rules: WordPress probes, which this site never serves wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) ``` -A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from, -adds it: +A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run +from, adds it: ```dockerfile -COPY 50-site.rules /etc/smallwebwaf/rules.d/50-site.rules +COPY 50-site.rules /etc/sww/rules.d/50-site.rules ``` -Or mount it there. A `path` rule applies on every site behind that `servewaf`. +Or mount it there. A `path` rule applies on every site behind that +`discriminator`. diff --git a/docs/settings.md b/docs/settings.md index e75d56f..fb621eb 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 `SWWAF_` are `servewaf`'s. Those that start with -`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND` -chooses between the two (see "The image's command" below). +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). ## Forms of values @@ -28,252 +28,250 @@ chooses between the two (see "The image's command" below). ## Core -- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a +- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a port, such as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)). -- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance` - name in each line `servewaf` logs, and in each alert and metric, such as - `fsn1/servewaf`. Valid UTF-8. No control character while - `SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a - space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is - not. -- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`. -- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 - client's group, from 32 to 128. -- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole +- `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 + `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. -- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps, - a whole number above zero. +- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator` + keeps, a whole number above zero. ## Tokens -- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `/_smallwebwaf/clients/`. At least 32 characters. -- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`. - At least 32 characters. +- `SWW_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. ## State files -- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state - files, an absolute path that exists and can be written. -- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be +- `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 written after a ban is made, lifted or made permanent. A duration. -- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is +- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is written. A duration. ## Logging -- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own +- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`. -- `SWWAF_LOG_REQUEST_HEADERS` (default +- `SWW_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`. -- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every +- `SWW_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. -- `SWWAF_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. -- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they - wait to be sent, a whole number above zero. -- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, +- `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`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `authpriv`, `ftp`, or `local0` to `local7`. -- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in - each syslog record, 1 to 48 printable ASCII characters without a space. +- `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. ## Metrics -- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many - countries get series of their own. A whole number above zero. +- `SWW_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 -- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your - monitoring (see step 3 in [`SPEC.md`](../SPEC.md)). -- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are +- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring + (see step 3 in [`SPEC.md`](../SPEC.md)). +- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are exempt from rate limiting and the byte limits. -- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see - step 3 in [`SPEC.md`](../SPEC.md)). +- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step + 3 in [`SPEC.md`](../SPEC.md)). ## Request rate limits, per client -- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a +- `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 person opening two pages at once with an empty cache, up to 60 files each. -- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may +- `SWW_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. -- `SWWAF_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_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 `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a + the runners' addresses in `SWW_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)). -- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - rate limiting and the byte limits, such as `/assets/`. +- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate + limiting and the byte limits, such as `/assets/`. ## Byte limits, per client -- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR` - (default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most - bytes counted for a client in each window. A size or `off`. -- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: +- `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` (default `both`): which body bytes the byte limits count: `response`, `request` or `both`. ## Lookup of AS number and country -- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops +- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops the start while a setting that needs a lookup is set: - the two country lists; - - `SWWAF_ADD_LOOKUP_HEADERS`; - - the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`; - - `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`; + - `SWW_ADD_LOOKUP_HEADERS`; + - the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`; + - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - an anomaly threshold per AS number. -- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. -- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its +- `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 client's first answer from GeoJS, and how long a request to GeoJS may take. A duration; never `off`. -- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app +- `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 `X-Client-Country`. ## Country lists -- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused, +- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused, such as `cn,ru,kp`. -- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only +- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only countries whose clients get through, such as `us,de`. A country on both lists stops the start. ## Biased thresholds -- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` +- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number listed twice is refused. -- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as +- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as `cn:25,ru:50`. -- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty): - the same, for the byte limits alone. -- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the +- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the + same, for the byte limits alone. +- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the lookup cannot place. -- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a +- `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`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused. + `AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused. ## Attack detection -- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the - rule files. -- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file. -- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as +- `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 `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. -- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`. -- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, +- `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. -- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a +- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a match. A whole number above zero, or `off`. -- `SWWAF_WAF_DISABLED_RULES` (default +- `SWW_WAF_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. -- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does +- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does not inspect. -- `SWWAF_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)). -- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set +- `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 `SWWAF_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. -- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. + 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 of one client in + a minute (see "Attack detection" in `SPEC.md`). A count or `off`. ## Ban lengths, response and scope -- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. -- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A +- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. +- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A duration. -- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends +- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends within which breaking a limit again lengthens the next ban. A duration. -- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; - one that would be longer is permanent. A duration. -- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of +- `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. -- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client +- `SWW_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 -- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as +- `SWW_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. -- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its - last try before it is fetched again. A duration of `1h` or more. -- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:` or `log`. -- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as +- `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 `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. -- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, +- `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. -- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A +- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A control character in it is refused. -- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, +- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, from 0 to 100, that is a hit. -- `SWWAF_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. -- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine +- `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 - `SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`. -- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for + `SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`. +- `SWW_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. -- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:` or +- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:` or `log`, for DNS blocklist and AbuseIPDB hits. -- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or +- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or AbuseIPDB's score is used. A duration. -- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a - check with AbuseIPDB may take. A duration. +- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check + with AbuseIPDB may take. A duration. ## Alerting -- `SWWAF_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. -- `SWWAF_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. -- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook - that receives each alert as a message, checked as the webhook URL is. -- `SWWAF_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. -- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control +- `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 character in it is refused. -- `SWWAF_ALERT_EVENTS` (default +- `SWW_ALERT_EVENTS` (default `ban,permanent_ban,waf_block,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. -- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its +- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its repeats are held back. A duration or `off`. -- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A +- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A whole number above zero, or `off`. ## Anomaly thresholds @@ -282,25 +280,25 @@ Each scope has four thresholds: the most requests and the most bytes counted in a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by default. The four thresholds per client are: -- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; -- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; -- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`; -- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`. +- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; +- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; +- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`; +- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`. The other scopes' names have another prefix before the same four endings, such as `REQUESTS_PER_MINUTE`: -- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the +- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the netblock's length: - - `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 + - `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 client; - - `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 + - `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 client. -- `SWWAF_ANOMALY_ASN_...`: per AS number. -- `SWWAF_ANOMALY_TOTAL_...`: the whole service. -- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole. - `SWWAF_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_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 name, or with a bad netblock, is refused. So is a name used twice. ## Settings given as files @@ -319,9 +317,9 @@ Then mount its directory into the container read-only, and name the file: ```sh docker run \ - -v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \ - -e SMALLWEBWAF_COMMAND=servewaf \ - -e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \ + -v /srv/app/tokens:/etc/sww/tokens:ro \ + -e SWW_COMMAND=discriminator \ + -e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ ... ``` @@ -334,41 +332,42 @@ A few limits are fixed rather than settings: - at most 1,000 DNS blocklist queries are under way at once; - at most 20,000 anomaly counters are kept; - each alert destination's queue holds at most 1,000 alerts; -- `servewaf` holds at most 10,000 answered requests awaiting their reports; -- `servewaf` refuses a request with more than 50 cookies or more than 100 header - fields (see "Answering in time" under "`servewaf`" in +- `discriminator` holds at most 10,000 answered requests awaiting their reports; +- `discriminator` refuses a request with more than 50 cookies or more than 100 + header fields (see "Answering in time" under "`discriminator`" in [`SPEC.md`](../SPEC.md)); - `rproxy` takes a request line and headers of at most 32 KiB, and closes a kept-open connection idle for 120 seconds; -- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers - `502` to a longer one. +- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and + answers `502` to a longer one. -## `servewaf`'s answer and `rproxy`'s wait +## `discriminator`'s answer and `rproxy`'s wait -`SWWAF_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 `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are -asked in the background (see "Answering in time" under "`servewaf`" in +`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)). ## `rproxy` -`smallwebwaf 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. +`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. A time here is a whole number of milliseconds above zero, such as `2000`. -- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or +- `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 `servewaf`'s - answer, to a question and to a report. A time; never `off`. +- `SWRP_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 - `servewaf` 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 `servewaf` gives, such as an error. + `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 @@ -392,22 +391,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`. ## The image's command -`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what -its health check checks: `rproxy` or `servewaf` (see "Deployment" in +`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health +check checks: `rproxy` or `discriminator` (see "Deployment" in [`SPEC.md`](../SPEC.md)). It cannot be given as a file. ## Where earlier settings went Nothing reads these names any more: -- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. -- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. -- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. -- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. -- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are +- `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. -- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are +- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are `SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. -- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have - no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and +- `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. diff --git a/docs/state-files.md b/docs/state-files.md index 509dc92..a766394 100644 --- a/docs/state-files.md +++ b/docs/state-files.md @@ -1,13 +1,13 @@ # State files -The five JSON files in `SWWAF_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_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 `SWWAF_MAX_BANS` of -those `servewaf` made. Per entry: +Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those +`discriminator` made. Per entry: - `netblock`, `start` and `expires` (`null` for a permanent ban); - `cause`: `attack`, `limit`, `admin` or `crowdsec`; @@ -93,10 +93,10 @@ GeoJS answers, one per line: ## Sizes - `clients.json`: about 1 KiB per client, about 20 MiB at the default - `SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of + `SWW_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 `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to + the default `SWW_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/internal/alerts/alerts.go b/internal/alerts/alerts.go index 422433c..1e174b6 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 -// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON +// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // object" in docs/alerts.md describes, to the Slack incoming -// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the -// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within -// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past -// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a +// 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 // 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,41 +34,41 @@ import ( "time" ) -// The events an alert is for, as SWWAF_ALERT_EVENTS names them. +// The events an alert is for, as SWW_ALERT_EVENTS names them. const ( - // EventBan is a ban smallwebwaf made. + // EventBan is a ban sww made. EventBan = "ban" - // EventPermanentBan is a permanent ban smallwebwaf made, or a ban it + // EventPermanentBan is a permanent ban sww made, or a ban it // made permanent. EventPermanentBan = "permanent_ban" // EventAnomaly is a count of requests or bytes over an anomaly // threshold. EventAnomaly = "anomaly" // EventWAFBlock is a request the Core Rule Set scored at or over - // SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in + // SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // detect mode. EventWAFBlock = "waf_block" // EventReputationHit is a request whose client a blocklist, the // CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score // is a hit. EventReputationHit = "reputation_hit" - // EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch + // EventSourceFailure is GeoJS failing or refusing sww, a fetch // of a list failing, a query to a DNSBL zone or a check with AbuseIPDB // failing or refused, or the day's AbuseIPDB checks used up. EventSourceFailure = "source_failure" - // EventFileError is a rule file or state file edited while smallwebwaf + // EventFileError is a rule file or state file edited while sww // runs that does not parse, a replacement of the lookup database that // 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 SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held + // back in it past SWW_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 SWWAF_ALERT_MAX_PER_HOUR off too, for - // those repeats. SWWAF_ALERT_EVENTS does not name it. + // has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for + // those repeats. SWW_ALERT_EVENTS does not name it. EventSummary = "summary" ) -// Events returns every event SWWAF_ALERT_EVENTS can name, which is its +// Events returns every event SWW_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 SWWAF_ALERT_WEBHOOK_URL names. + // DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names. DestinationWebhook = "webhook" // DestinationSlack is the Slack incoming webhook - // SWWAF_ALERT_SLACK_WEBHOOK_URL names. + // SWW_ALERT_SLACK_WEBHOOK_URL names. DestinationSlack = "slack" - // DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names. + // DestinationNtfy is the ntfy topic SWW_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 - // (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders - // are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). + // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders + // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). WebhookURL *url.URL WebhookHeaders http.Header // SlackURL is the Slack incoming webhook each alert is posted to as a - // message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. + // message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. SlackURL *url.URL // NtfyURL is the ntfy topic each alert is published to - // (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless - // empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN). + // (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless + // empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN). NtfyURL *url.URL NtfyToken string - // Events are the events alerts are sent for (SWWAF_ALERT_EVENTS). + // Events are the events alerts are sent for (SWW_ALERT_EVENTS). Events []string // Cooldown is how long a repeat of an alert is held back - // (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts - // sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit. + // (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. Cooldown time.Duration MaxPerHour int - // Instance is SWWAF_INSTANCE_NAME, which every alert gives. + // Instance is SWW_INSTANCE_NAME, which every alert gives. Instance string // Now tells the time of an alert, normally time.Now in UTC. Now func() time.Time @@ -151,7 +151,7 @@ type Params struct { // Alert is one alert, as the webhook is sent it and alerts.json holds it, // with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and // Country are, for a ban, the client's as the ban's notes give them. Host is -// the site of the request that raised the alert, while one servewaf serves +// the site of the request that raised the alert, while one discriminator serves // many sites, and "" otherwise. // //nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case @@ -301,17 +301,17 @@ func New(params Params) *Queue { } if params.WebhookURL != nil { - q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL", + q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL", params.WebhookURL, q.webhookMessage) } if params.SlackURL != nil { - q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL", + q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL", params.SlackURL, slackMessage) } if params.NtfyURL != nil { - q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL", + q.addDestination(DestinationNtfy, "SWW_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 SWWAF_ALERT_EVENTS leaves its event +// unless no destination is set or SWW_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, SWWAF_ALERT_EVENTS chooses +// netblock through now: a destination is set, SWW_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. @@ -606,13 +606,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 SWWAF_ALERT_MAX_PER_HOUR allows", heldBack, + "past the %d an hour SWW_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 "+ - "SWWAF_ALERT_COOLDOWN that no later alert gives", repeats)) + "SWW_ALERT_COOLDOWN that no later alert gives", repeats)) } if len(reasons) > 0 { @@ -683,7 +683,7 @@ func (d *destination) run(ctx context.Context) { d.processLog.Warn("gave up an alert "+d.setting+" refused", "event", alert.Event, "error", err.Error()) - case ctx.Err() == nil: // not cut off as smallwebwaf stops + case ctx.Err() == nil: // not cut off as sww stops d.failed.Add(1) retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 663ff02..5691e9c 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -34,9 +34,9 @@ const ( // webhookURL is where the alerts are posted, slackURL the Slack // incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy // token of the tests that set one. - webhookURL = "https://alerts.example/smallwebwaf?team=ops" + webhookURL = "https://alerts.example/sww?team=ops" slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef" - ntfyURL = "https://ntfy.example/smallwebwaf-alerts" + ntfyURL = "https://ntfy.example/sww-alerts" ntfyToken = "tk_0123456789abcdefghijklmnopq" // instance is the instance name every alert gives. instance = "fsn1app1/blog" @@ -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 SWWAF_ALERT_EVENTS leaves it out") + t.Error("a ban alert would be sent, though SWW_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{ - "SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack, - "SWWAF_ALERT_NTFY_URL": onlyNtfy, + "SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack, + "SWW_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 SWWAF_ALERT_MAX_PER_HOUR allows", + "past the 2 an hour SWW_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 SWWAF_ALERT_WEBHOOK_URL failed"`) { + `"msg":"sending an alert to SWW_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 SWWAF_ALERT_WEBHOOK_URL refused"`) { + `"msg":"gave up an alert SWW_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 SWWAF_ALERT_WEBHOOK_URL failed"`) || + `"msg":"sending an alert to SWW_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 SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + - "by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" + "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" 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 SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) || + `"msg":"sending an alert to SWW_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 SWWAF_ALERT_EVENTS +// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS // names, the first in observe mode, each with its message. func anAlertForEachEvent() []eventMessage { return []eventMessage{ @@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage { Event: alerts.EventFileError, Reason: "a rule file has an error, and the rules stay as they were", Detail: map[string]any{ - "file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action", + "file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action", }, }, "fsn1app1/blog: file_error", "high warning", "a rule file has an error, and the rules stay as they were\n" + - "file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action", + "file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action", }, } } diff --git a/internal/anomaly/anomaly.go b/internal/anomaly/anomaly.go index 3c4734d..9df8097 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 - // SWWAF_IPV6_GROUP_PREFIX. + // SWW_IPV6_GROUP_PREFIX. ScopeClient = "client" - // ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX - // or SWWAF_ANOMALY_NET_V6_PREFIX long. + // ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX + // or SWW_ANOMALY_NET_V6_PREFIX long. ScopeNet = "net" // ScopeASN is an AS number. ScopeASN = "asn" // ScopeTotal is the whole service. ScopeTotal = "total" - // ScopeWatch is a named netblock of SWWAF_WATCH_NETS. + // ScopeWatch is a named netblock of SWW_WATCH_NETS. ScopeWatch = "watch" ) @@ -62,7 +62,7 @@ type Thresholds struct { BytesPerHour int64 } -// NamedNetblock is a netblock SWWAF_WATCH_NETS names. +// NamedNetblock is a netblock SWW_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: SWWAF_ANOMALY_CLIENT_*, - // SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and - // SWWAF_WATCH_*. + // The thresholds of each scope: SWW_ANOMALY_CLIENT_*, + // SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and + // SWW_WATCH_*. Client, Net, ASN, Total, Watch Thresholds // NetV4Prefix and NetV6Prefix are the lengths of the netblock around a - // client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX). + // client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX). NetV4Prefix, NetV6Prefix int - // NamedNetblocks are SWWAF_WATCH_NETS. + // NamedNetblocks are SWW_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 - // SWWAF_IPV6_GROUP_PREFIX its address is in. + // SWW_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 SWWAF_BYTES_COUNT counts them. + // Bytes are the request's bytes, as SWW_BYTES_COUNT counts them. Bytes int64 } diff --git a/internal/bans/admin_test.go b/internal/bans/admin_test.go index 4393f6c..e695dad 100644 --- a/internal/bans/admin_test.go +++ b/internal/bans/admin_test.go @@ -33,7 +33,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { third := netip.MustParsePrefix("203.0.113.3/32") // Seen longest ago, a netblock with two of an admin's bans alone, and - // then one with an admin's ban before a ban smallwebwaf made: the one + // then one with an admin's ban before a ban sww made: the one // ban counted toward MaxBans. ledger.Load([]bans.Ban{ {Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin}, @@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { }) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2}) - // A new ban drops the ban smallwebwaf made, and only that one. + // A new ban drops the ban sww made, and only that one. ledger.BanForLimit(second, midnight(), bans.Notes{}) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}) @@ -114,7 +114,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) { } } -func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) { +func TestReasonOfTheBansSwwMakes(t *testing.T) { t.Parallel() ledger := bans.New(defaultRules()) @@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { t.Fatalf("%d bans made by an admin after the start's, want none", got) } - // The admin keeps the ban smallwebwaf made, keeps the one read at the + // The admin keeps the ban sww made, keeps the one read at the // start, and adds one without a cause: that one alone is made. kept := made kept.Cause = bans.CauseAdmin diff --git a/internal/bans/bans.go b/internal/bans/bans.go index e3848d8..9256b3f 100644 --- a/internal/bans/bans.go +++ b/internal/bans/bans.go @@ -1,4 +1,4 @@ -// Package bans is the ban ledger: the bans smallwebwaf makes on the +// Package bans is the ban ledger: the bans sww makes on the // netblocks of clients that break a rate limit, a byte limit or the error // burst, show a clear sign of attack or are listed by the CrowdSec // decision list, and @@ -21,14 +21,14 @@ import ( // The causes of bans. const ( - // CauseLimit is a ban smallwebwaf made for a broken limit. + // CauseLimit is a ban sww made for a broken limit. CauseLimit = "limit" - // CauseAttack is a ban smallwebwaf made for a clear sign of attack. + // CauseAttack is a ban sww made for a clear sign of attack. CauseAttack = "attack" - // CauseAdmin is a ban an admin made, or one smallwebwaf made that an + // CauseAdmin is a ban an admin made, or one sww made that an // admin keeps. It is never dropped. CauseAdmin = "admin" - // CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec + // CauseCrowdSec is a ban sww made for a client the CrowdSec // decision list lists. It ends when CrowdSec's decision does. CauseCrowdSec = "crowdsec" ) @@ -70,7 +70,7 @@ type Ban struct { Expires time.Time // Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec. Cause string - // Reason is a short text: for a ban smallwebwaf made, the limit broken, + // Reason is a short text: for a ban sww made, the limit broken, // the rule that matched or the scenario of CrowdSec's decision; for an // admin's, what the admin wrote. Reason string @@ -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 SWWAF_TRAP_PATHS, that path as the + // one for a request for a path in SWW_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 SWWAF_BAN_SCOPE_V4_PREFIX, + // netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX, // or another length of an IPv6 client's netblock. v4Lengths, v6Lengths []int } @@ -581,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) { } // LoadEdit is Load for an admin's edit of bans.json, taken in while -// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which +// sww runs. Each ban in it whose cause is CauseAdmin, and which // the ledger did not hold, with the same netblock and start, is one the // admin made, and is counted among the bans made. func (l *Ledger) LoadEdit(bans []Ban) { diff --git a/internal/bans/snapshot_test.go b/internal/bans/snapshot_test.go index 2c6ecb4..0c47dae 100644 --- a/internal/bans/snapshot_test.go +++ b/internal/bans/snapshot_test.go @@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) { func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { t.Parallel() - // A 9-hour ban smallwebwaf made, the third in a row, and an admin's + // A 9-hour ban sww made, the third in a row, and an admin's // 1-hour ban added to bans.json over it, with no cause and no notes. netblock := netip.MustParsePrefix("203.0.113.9/32") nineHours := bans.Ban{ diff --git a/internal/config/config.go b/internal/config/config.go index f25b590..3a5ad3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,9 @@ -// Package config reads smallwebwaf's settings, and is the one place they are -// read. Every setting is an environment variable. The settings of `smallwebwaf -// servewaf` start with SWWAF_, every one of them has a default, and most can be -// given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each +// 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. SMALLWEBWAF_COMMAND chooses which of the two +// since rproxy never touches disk. SWW_COMMAND chooses which of the two // the image runs. package config @@ -31,88 +31,88 @@ import ( "sneak.berlin/go/smallwebwaf/internal/remotelog" ) -// Config is the settings of `smallwebwaf servewaf`. A time or size limit of +// Config is the settings of `sww discriminator`. A time or size limit of // zero is off. type Config struct { - // ListenAddr is where servewaf listens (SWWAF_LISTEN_ADDR). + // ListenAddr is where discriminator listens (SWW_LISTEN_ADDR). ListenAddr string // InstanceName is the name every log line and alert gives as instance, - // and every metric carries as its label instance (SWWAF_INSTANCE_NAME), + // and every metric carries as its label instance (SWW_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 SWWAF_MODE is observe rather - // than enforce: a request that SWWAF_DENY_NETS, a ban, the country + // Observe is true in observe mode, when SWW_MODE is observe rather + // than enforce: a request that SWW_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 - // (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128. + // (SWW_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 (SWWAF_MAX_TRACKED_CLIENTS). + // memory and in clients.json (SWW_MAX_TRACKED_CLIENTS). MaxTrackedClients int // AllowNets are the netblocks whose clients skip every check - // (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the - // rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS). + // (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the + // rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS). // DenyNets are those whose clients are always refused - // (SWWAF_DENY_NETS). + // (SWW_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 - // (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and - // SWWAF_RATE_LIMIT_PER_DAY). + // (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and + // SWW_RATE_LIMIT_PER_DAY). RateLimitPerMinute int64 RateLimitPerHour int64 RateLimitPerDay int64 // RateLimitExemptPaths are the path prefixes whose requests the rate - // limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS). + // limits neither count nor refuse (SWW_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 (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and - // SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count - // toward them (SWWAF_BYTES_COUNT): response, request or both. + // 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. BytesLimitPerMinute int64 BytesLimitPerHour int64 BytesLimitPerDay int64 BytesCount string // LookupSource is where each client's AS number and country are - // looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. + // looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere. // LookupDBPath is the lookup database, the IPinfo Lite file looked up - // in while LookupSource is file (SWWAF_LOOKUP_DB_PATH), and "" for any + // in while LookupSource is file (SWW_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 - // (SWWAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true + // (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true // when the app is passed the client's AS number and country in headers - // (SWWAF_ADD_LOOKUP_HEADERS). + // (SWW_ADD_LOOKUP_HEADERS). LookupSource string LookupDBPath string LookupTimeout time.Duration AddLookupHeaders bool // DeniedCountries are the countries whose clients are refused - // (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not + // (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not // empty, are the only countries whose clients are let through - // (SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in + // (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in // capitals, as GeoJS gives them. DeniedCountries []string ExclusivelyAllowedCountries []string // The biased thresholds. ASNLimitPercent and CountryLimitPercent give // the clients of the AS numbers and the countries they list that // percentage of every rate limit and byte limit - // (SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT). + // (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT). // ASNBytesPercent and CountryBytesPercent give those they list a // percentage of the byte limits in place of that one - // (SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT). Each holds + // (SWW_ASN_BYTES_PERCENT and SWW_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 (SWWAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is + // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is // where a file of AS:percent lines is fetched from, whose percentages - // count as those of ASNLimitPercent do (SWWAF_ASN_LIMIT_PERCENT_URL), "" + // count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), "" // while it is unset. ASNLimitPercent map[string]int64 CountryLimitPercent map[string]int64 @@ -121,30 +121,30 @@ type Config struct { UnknownLimitPercent int64 ASNLimitPercentURL string // BlocklistURLs are where the blocklists are fetched from - // (SWWAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is + // (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is // fetched again BlocklistRefresh after it was last fetched or tried - // (SWWAF_BLOCKLIST_REFRESH), which is never less than an hour. + // (SWW_BLOCKLIST_REFRESH), which is never less than an hour. // BlocklistAction is what is done with a client a blocklist lists - // (SWWAF_BLOCKLIST_ACTION): deny, limit or log; for limit, + // (SWW_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 - // (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or + // (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_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 (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score - // of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and + // 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 // at most AbuseIPDBDailyBudget checks are made a day - // (SWWAF_ABUSEIPDB_DAILY_BUDGET). + // (SWW_ABUSEIPDB_DAILY_BUDGET). // ReputationAction is what is done with a client a zone's verdict lists, - // or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log; + // or whose score is a hit (SWW_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 (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take - // ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off. + // fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take + // ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off. DNSBLZones []string DNSBLResolver netip.AddrPort AbuseIPDBKey string @@ -155,74 +155,74 @@ type Config struct { ReputationCacheTTL time.Duration ReputationTimeout time.Duration // CrowdSecDecisionsURL is where the decision list of the CrowdSec - // engine whose local API SWWAF_CROWDSEC_LAPI_URL names is fetched from: + // engine whose local API SWW_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 - // (SWWAF_CROWDSEC_LAPI_KEY). + // (SWW_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 - // (SWWAF_BAN_RESPONSE). It answers a banned client, a request that - // breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the + // (SWW_BAN_RESPONSE). It answers a banned client, a request that + // breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the // country lists. BanResponse int // LimitBanDuration is the ban for a first broken rate limit - // (SWWAF_LIMIT_BAN_DURATION). A limit broken again within + // (SWW_LIMIT_BAN_DURATION). A limit broken again within // LimitBanRepeatWindow after the last ban ended bans for three times - // as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that + // as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that // would be longer than MaxBanDuration is permanent instead - // (SWWAF_MAX_BAN_DURATION). None of them can be off. + // (SWW_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 - // (SWWAF_ATTACK_BAN_DURATION). It cannot be off. + // (SWW_ATTACK_BAN_DURATION). It cannot be off. AttackBanDuration time.Duration - // MaxBans is the most bans held (SWWAF_MAX_BANS). + // MaxBans is the most bans held (SWW_MAX_BANS). MaxBans int // BanScopeV4Prefix is the length of the netblock around an IPv4 - // client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX). + // client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX). BanScopeV4Prefix int // StateDir is the directory of the state files, an absolute path - // (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban - // is made (SWWAF_STATE_WRITE_DELAY), and every state file every - // StateCounterInterval (SWWAF_STATE_COUNTER_INTERVAL). Neither can be + // (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 // off. StateDir string StateWriteDelay time.Duration StateCounterInterval time.Duration // LogRequestHeaders are the request headers whose values the request - // log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS). + // log gives, in lower case (SWW_LOG_REQUEST_HEADERS). LogRequestHeaders []string // LogLevel is the least severe of the process's own messages that are - // written (SWWAF_LOG_LEVEL). It holds back no request log line. + // written (SWW_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 /_smallwebwaf/clients/ (SWWAF_ADMIN_TOKEN), "" while it is + // and /_sww/clients/ (SWW_ADMIN_TOKEN), "" while it is // unset and they are off. AdminToken string // MetricsToken is the bearer token a scraper sends for the metrics - // (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off. + // (SWW_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 (SWWAF_METRICS_TOP_N). + // of their own in the metrics (SWW_METRICS_TOP_N). MetricsToken string MetricsTopN int - // RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read - // unless RulesEnabled is false (SWWAF_RULES_ENABLED). + // RulesDir is the directory of the rule files (SWW_RULES_DIR), read + // unless RulesEnabled is false (SWW_RULES_ENABLED). RulesDir string RulesEnabled bool - // WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff, + // WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff, // WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia - // level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and + // level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and // WAFAnomalyThreshold the anomaly score at which a request is a match - // (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules - // are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES), + // (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules + // are the ids of its rules switched off (SWW_WAF_DISABLED_RULES), // WAFExemptPaths the path prefixes it does not inspect - // (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body - // it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. + // (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. // CoreRuleSetExclusionsFile is the path of the file of its exclusions - // (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. + // (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. WAFMode string WAFParanoiaLevel int WAFAnomalyThreshold int @@ -231,21 +231,21 @@ type Config struct { WAFBodyLimit int64 CoreRuleSetExclusionsFile string // TrapPaths are the paths a request for which is a clear sign of - // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. + // attack (SWW_TRAP_PATHS), each starting with / and without a ?. TrapPaths []string // ErrorBurstThreshold is the most requests of a client within a minute - // that servewaf may refuse after a rule file or Core Rule Set match; one - // more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off. + // 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. ErrorBurstThreshold int64 // LogRemoteURL is where every line on stdout is also sent - // (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent. + // (SWW_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 (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil while + // certificate must chain to (SWW_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 (SWWAF_LOG_REMOTE_BUFFER). + // lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER). // LogRemoteFacility is the number of the syslog facility - // (SWWAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME - // (SWWAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records + // (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME + // (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records // the lines are sent in. LogRemoteURL *url.URL LogRemoteTLSCAs *x509.CertPool @@ -253,18 +253,18 @@ type Config struct { LogRemoteFacility int LogRemoteAppName string // AlertWebhookURL is where each alert is posted as JSON - // (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders - // are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). + // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders + // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). // AlertSlackWebhookURL is the Slack incoming webhook each alert is - // posted to as a message (SWWAF_ALERT_SLACK_WEBHOOK_URL), and + // posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and // AlertNtfyURL the ntfy topic each is published to - // (SWWAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, - // unless empty, is sent to ntfy with each (SWWAF_ALERT_NTFY_TOKEN). + // (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, + // unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN). // With none of the three URLs set, no alert is sent. AlertEvents are - // the events alerts are sent for (SWWAF_ALERT_EVENTS). A repeat of an - // alert within AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and + // the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an + // alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and // so is an alert past AlertMaxPerHour in an hour, for the hour's - // summary (SWWAF_ALERT_MAX_PER_HOUR); 0 is off for both. + // summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both. AlertWebhookURL *url.URL AlertWebhookHeaders http.Header AlertSlackWebhookURL *url.URL @@ -274,14 +274,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 (SWWAF_ANOMALY_CLIENT_*), - // per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number - // (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*) - // and per named netblock (SWWAF_WATCH_*), each 0 while it is off. + // 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. // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the - // netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and - // SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks - // (SWWAF_WATCH_NETS). + // netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and + // SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks + // (SWW_WATCH_NETS). AnomalyClient anomaly.Thresholds AnomalyNet anomaly.Thresholds AnomalyASN anomaly.Thresholds @@ -300,7 +300,7 @@ type Config struct { // off. const off = "off" -// The values of SWWAF_WAF_MODE. +// The values of SWW_WAF_MODE. const ( // WAFModeOff runs no request through the Core Rule Set. WAFModeOff = off @@ -310,8 +310,8 @@ const ( WAFModeBlock = "block" ) -// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the -// lookup database, the file SWWAF_LOOKUP_DB_PATH names. +// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the +// lookup database, the file SWW_LOOKUP_DB_PATH names. const fileSource = "file" const ( @@ -321,7 +321,7 @@ const ( gibibyte = 1 << 30 ipv4Bits = 32 ipv6Bits = 128 - // minIPv6GroupPrefix is the shortest SWWAF_IPV6_GROUP_PREFIX, the + // minIPv6GroupPrefix is the shortest SWW_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 @@ -330,7 +330,7 @@ const ( // maxParanoiaLevel is the Core Rule Set's highest paranoia level. maxParanoiaLevel = 4 // firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set - // keeps for the rules that set it up, which smallwebwaf's own rules + // keeps for the rules that set it up, which sww's own rules // have too (see internal/waf). Switching one off would undo a change // that no setting undoes. firstSetupRuleID = 900000 @@ -338,7 +338,7 @@ 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 SWWAF_LISTEN_ADDR and + // defaultListenAddr is the default of SWW_LISTEN_ADDR and // SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL. defaultListenAddr = ":8080" defaultUpstreamURL = "http://127.0.0.1:8081" @@ -367,14 +367,14 @@ 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 SWWAF_DENIED_COUNTRIES too") + errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES 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") errDBPathUnused = errors.New("only file reads it") errOver64K = errors.New( - "is more than 64K, the most servewaf gets of a body") - errNotCommand = errors.New("is not rproxy or servewaf") + "is more than 64K, the most discriminator gets of a body") + errNotCommand = errors.New("is not rproxy or discriminator") errNotDurationAboveZero = errors.New( "is not a duration above zero, such as 1h or 7d") errNotNumberAboveZero = errors.New( @@ -390,7 +390,7 @@ var ( errNotNamedNetblock = errors.New( "is not a name, = and a netblock, such as office=203.0.113.0/24") errNotAbsolutePath = errors.New( - "is not an absolute path, such as /var/lib/smallwebwaf") + "is not an absolute path, such as /var/lib/sww") errShortToken = errors.New("is shorter than 32 characters") errNotMode = errors.New("is not enforce or observe") errNotBytesCount = errors.New("is not response, request or both") @@ -404,7 +404,7 @@ var ( "is not the id of a Core Rule Set rule, a whole number such as 942100") errSetupRuleID = errors.New( "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + - "up and of smallwebwaf's own, which cannot be switched off") + "up and of sww's own, which cannot be switched off") errNotBoolean = errors.New("is not true or false") errNotLogRemoteURL = errors.New( "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + @@ -416,7 +416,7 @@ var ( errSetTwice = errors.New("set only one of them") errNotWebhookURL = errors.New( "is not an http or https URL without a user or a fragment, " + - "such as https://alerts.example/smallwebwaf") + "such as https://alerts.example/sww") errNotWebhookHeader = errors.New( "is not a header name followed by : and the header's value, " + "such as Authorization:Bearer ") @@ -435,7 +435,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 SWWAF_BLOCKLIST_URLS too") + errInBlocklistURLs = errors.New("is in SWW_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") @@ -450,7 +450,7 @@ 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 SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too") + "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") ) // FromEnvironment reads the settings with lookupEnv, normally @@ -463,104 +463,104 @@ var ( func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { env := &environment{lookupEnv: lookupEnv} cfg := &Config{ - ListenAddr: env.address("SWWAF_LISTEN_ADDR"), + ListenAddr: env.address("SWW_LISTEN_ADDR"), InstanceName: env.instanceName(), - Observe: env.observe("SWWAF_MODE", "enforce"), - IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"), - MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"), - AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), - RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), - DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), - RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"), - RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), - RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), - RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), - BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"), - BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), - BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), - BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), - LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), - LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), - LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"), - AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), - DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), + 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"), + DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""), ExclusivelyAllowedCountries: env.countries( - "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), - ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN), - CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry), - ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN), - CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry), - UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"), - ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), - BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), - BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), - DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), - DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), - AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), - AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), - AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"), - ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"), - ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"), - CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"), - CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), - BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), - LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), - LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), - MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), - AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), - MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), - BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), - StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), - StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), - StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), - LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", + "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), + ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN), + CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry), + 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", "accept,accept-language,accept-encoding,content-type,origin,range"), - LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), - AdminToken: env.token("SWWAF_ADMIN_TOKEN"), - MetricsToken: env.token("SWWAF_METRICS_TOKEN"), - MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), - RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), - RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), - WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), - WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"), - WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"), - WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", + 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", "920340,920420,920440,920640,930130,930140"), - WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), - WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), + WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""), + WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), CoreRuleSetExclusionsFile: env.filePath( - "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"), - TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), - ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), - LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), - LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"), - LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"), - LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"), - AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), - AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"), - AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"), - AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), - AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), - AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", + "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", strings.Join(alerts.Events(), ",")), - AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), - AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"), - AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), - AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), - AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), - AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), - AnomalyWatch: env.thresholds("SWWAF_WATCH_"), - AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"), - AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"), - WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), + 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"), } - cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME", + cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME", cfg.InstanceName, cfg.LogRemoteURL != nil) cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action( - "SWWAF_BLOCKLIST_ACTION", "deny") + "SWW_BLOCKLIST_ACTION", "deny") cfg.ReputationAction, cfg.ReputationLimitPercent = env.action( - "SWWAF_REPUTATION_ACTION", "limit:25") + "SWW_REPUTATION_ACTION", "limit:25") env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) env.checkLookupDBPath(cfg) @@ -577,33 +577,33 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { return cfg, nil } -// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its -// health check checks: rproxy when it is unset or empty, or servewaf. It cannot +// 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("SMALLWEBWAF_COMMAND") + command, _ := lookupEnv("SWW_COMMAND") switch command { case "", "rproxy": return "rproxy", nil - case "servewaf": + case "discriminator": return command, nil default: - return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand) + return "", fmt.Errorf("SWW_COMMAND: %q %w", command, errNotCommand) } } -// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a -// file, as FromEnvironment does. The health check in servewaf's container needs +// DiscriminatorListenAddr reads only SWW_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 ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) { +func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) { env := &environment{lookupEnv: lookupEnv} - listenAddr := env.address("SWWAF_LISTEN_ADDR") + listenAddr := env.address("SWW_LISTEN_ADDR") return listenAddr, env.err } -// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a +// InstanceName reads only SWW_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. @@ -961,30 +961,30 @@ func (e *environment) lookupSource(name, defaultValue string) string { return source } -// checkLookupDBPath refuses SWWAF_LOOKUP_SOURCE=file without -// SWWAF_LOOKUP_DB_PATH, and SWWAF_LOOKUP_DB_PATH with any other source: +// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without +// SWW_LOOKUP_DB_PATH, and SWW_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("SWWAF_LOOKUP_SOURCE", fmt.Errorf( - "is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) + e.check("SWW_LOOKUP_SOURCE", fmt.Errorf( + "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": - e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( - "is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) + e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf( + "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) } } // checkCountriesAndLookups refuses a country on both country lists, and, -// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked -// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds, -// SWWAF_ASN_LIMIT_PERCENT_URL among them, of which -// SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a +// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked +// up: the country lists, SWW_ADD_LOOKUP_HEADERS, 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 // limit, and the anomaly thresholds per AS number. func (e *environment) checkCountriesAndLookups(cfg *Config) { for _, country := range cfg.ExclusivelyAllowedCountries { if slices.Contains(cfg.DeniedCountries, country) { - e.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", + e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", fmt.Errorf("%q %w", country, errOnBothLists)) } } @@ -997,52 +997,52 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) { name string set bool }{ - {"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0}, - {"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, - {"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, - {"SWWAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0}, - {"SWWAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0}, - {"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, - {"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, - {"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, - {"SWWAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, - {"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, - {"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, - {"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, - {"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, + {"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0}, + {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, + {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, + {"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0}, + {"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0}, + {"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}, } { if setting.set { - e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", + e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", errNeedsLookups)) } } } -// checkASNLimitPercentURL refuses SWWAF_ASN_LIMIT_PERCENT_URL naming a +// checkASNLimitPercentURL refuses SWW_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("SWWAF_ASN_LIMIT_PERCENT_URL", + e.check("SWW_ASN_LIMIT_PERCENT_URL", fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs)) } } -// checkCrowdSec refuses SWWAF_CROWDSEC_LAPI_URL without -// SWWAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list +// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without +// SWW_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("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf( - "is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) + e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf( + "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) case decisionsURL == "" && cfg.CrowdSecKey != "": - e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf( - "is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) + e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf( + "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || decisionsURL == cfg.ASNLimitPercentURL): - e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", + e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", decisionsURL, errAnotherList)) } } @@ -1241,14 +1241,14 @@ func (e *environment) facility(name, defaultValue string) int { return number } -// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It +// instanceName reads SWW_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("SWWAF_INSTANCE_NAME", hostname) + value := e.value("SWW_INSTANCE_NAME", hostname) if !utf8.ValidString(value) { - e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) + e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) } return value @@ -1270,7 +1270,7 @@ func (e *environment) appName(name, instanceName string, sending bool) string { case set: e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) case sending: - e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w", + e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", value, errNotAppName)) } @@ -1278,19 +1278,19 @@ func (e *environment) appName(name, instanceName string, sending bool) string { } // checkInstanceNameForNtfy refuses an instance name that holds a control -// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent +// character while ntfySet, SWW_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("SWWAF_INSTANCE_NAME", fmt.Errorf( - "%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", + e.check("SWW_INSTANCE_NAME", fmt.Errorf( + "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", instanceName, errControlCharacter)) } } // webhookURL reads a setting that is a URL each alert is posted to: -// SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or -// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted +// 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 // 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. @@ -1410,7 +1410,7 @@ func parseSize(value string) (int64, error) { } // parseWAFBodyLimit reads the most of a request body the Core Rule Set -// reads: a size as parseSize reads it, or off, but at most 64K, since servewaf +// reads: a size as parseSize reads it, or off, but at most 64K, since discriminator // gets no more of a body than that. func parseWAFBodyLimit(value string) (int64, error) { limit, err := parseSize(value) @@ -1739,7 +1739,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 SWWAF_ASN_LIMIT_PERCENT_URL names is +// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is // read with it too. func ParseASN(value string) (string, error) { digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS") @@ -1793,7 +1793,7 @@ func parsePercents( } // ParsePercent reads a percentage, a whole number from 0 to 100. The file -// SWWAF_ASN_LIMIT_PERCENT_URL names is read with it too. +// SWW_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 { @@ -2013,7 +2013,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 SWWAF_DNSBL_RESOLVER + // dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER // gives none. dnsPort = 53 ) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cc27ec6..f8cfd56 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,99 +23,99 @@ import ( // The settings, by name. const ( - listenAddr = "SWWAF_LISTEN_ADDR" - mode = "SWWAF_MODE" - ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" - maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" - allowNets = "SWWAF_ALLOW_NETS" - rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" - denyNets = "SWWAF_DENY_NETS" - rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" - rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" - rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" - rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" - bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" - bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" - bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" - bytesCount = "SWWAF_BYTES_COUNT" - lookupSource = "SWWAF_LOOKUP_SOURCE" - lookupDBPath = "SWWAF_LOOKUP_DB_PATH" - lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" - addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" - deniedCountries = "SWWAF_DENIED_COUNTRIES" - allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" - asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" - countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" - asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" - countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" - unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" - asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" - blocklistURLs = "SWWAF_BLOCKLIST_URLS" - blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH" - blocklistAction = "SWWAF_BLOCKLIST_ACTION" - dnsblZones = "SWWAF_DNSBL_ZONES" - dnsblResolver = "SWWAF_DNSBL_RESOLVER" - abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" - abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE" - abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET" - reputationAction = "SWWAF_REPUTATION_ACTION" - reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL" - reputationTimeout = "SWWAF_REPUTATION_TIMEOUT" - crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" - crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" - banResponse = "SWWAF_BAN_RESPONSE" - limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" - limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" - maxBanDuration = "SWWAF_MAX_BAN_DURATION" - attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" - maxBans = "SWWAF_MAX_BANS" - banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" - stateDir = "SWWAF_STATE_DIR" - stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" - stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" - adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name - metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name - metricsTopN = "SWWAF_METRICS_TOP_N" - instanceName = "SWWAF_INSTANCE_NAME" - logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" - logLevel = "SWWAF_LOG_LEVEL" - rulesDir = "SWWAF_RULES_DIR" - rulesEnabled = "SWWAF_RULES_ENABLED" - wafMode = "SWWAF_WAF_MODE" - wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL" - wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" - wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" - wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" - wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" - exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE" - trapPaths = "SWWAF_TRAP_PATHS" - errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" - logRemoteURL = "SWWAF_LOG_REMOTE_URL" - logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE" - logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER" - logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY" - logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME" - alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" - alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS" - alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL" - alertNtfyURL = "SWWAF_ALERT_NTFY_URL" - alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name - alertEvents = "SWWAF_ALERT_EVENTS" - alertCooldown = "SWWAF_ALERT_COOLDOWN" - alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" - anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" - anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" - watchNets = "SWWAF_WATCH_NETS" + 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" + deniedCountries = "SWW_DENIED_COUNTRIES" + allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES" + asnLimitPercent = "SWW_ASN_LIMIT_PERCENT" + countryLimitPercent = "SWW_COUNTRY_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" + 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" ) // The anomaly thresholds: each of the prefixes below, which name a scope, // followed by each of the four ends. const ( - anomalyClient = "SWWAF_ANOMALY_CLIENT_" - anomalyNet = "SWWAF_ANOMALY_NET_" - anomalyASN = "SWWAF_ANOMALY_ASN_" - anomalyTotal = "SWWAF_ANOMALY_TOTAL_" - watch = "SWWAF_WATCH_" + anomalyClient = "SWW_ANOMALY_CLIENT_" + anomalyNet = "SWW_ANOMALY_NET_" + anomalyASN = "SWW_ANOMALY_ASN_" + anomalyTotal = "SWW_ANOMALY_TOTAL_" + watch = "SWW_WATCH_" requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerHour = "REQUESTS_PER_HOUR" @@ -156,21 +156,21 @@ func loggedAnomalyDefaults() map[string]string { return logged } -// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and -// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN. +// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and +// defaultAlertCooldown that of SWW_ALERT_COOLDOWN. const ( defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," + "source_failure,file_error" defaultAlertCooldown = "15m" ) -// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL. +// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL. const defaultReputationCacheTTL = "24h" -// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES. +// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES. const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" -// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS. +// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS. const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + "content-type,origin,range" @@ -195,8 +195,8 @@ const ( otherToken = "fedcba9876543210fedcba9876543210" ) -// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and -// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines. +// 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. const ( instance = "fsn1app1/blog" remoteURL = "syslog+udp://192.0.2.1:514" @@ -208,7 +208,7 @@ const off = "off" // enabled is true, as a setting's value. const enabled = "true" -// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and +// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and // fileSource the source that is the lookup database. const ( defaultLookupSource = "geojs" @@ -255,12 +255,12 @@ func TestDefaults(t *testing.T) { AttackBanDuration: 7 * 24 * time.Hour, MaxBans: 5000, BanScopeV4Prefix: 32, - StateDir: "/var/lib/smallwebwaf", + StateDir: "/var/lib/sww", StateWriteDelay: 10 * time.Second, StateCounterInterval: 15 * time.Minute, MetricsToken: "", MetricsTopN: 50, - RulesDir: "/etc/smallwebwaf/rules.d", + RulesDir: "/etc/sww/rules.d", RulesEnabled: true, }) wantLookupSettings(t, cfg, config.Config{ @@ -540,14 +540,14 @@ func TestCoreRuleSetSettings(t *testing.T) { environment{ wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", - wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf", + wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf", }, config.Config{ WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFDisabledRules: []int{942100, 920350}, WAFExemptPaths: []string{"/api/", "/static/"}, WAFBodyLimit: 32 << 10, - CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf", + CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf", }, }, { @@ -591,7 +591,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) { const ( notParanoiaLevel = " is not a paranoia level, from 1 to 4" setupRule = " is from 900000 to 900999, the ids of the rules that set " + - "the Core Rule Set up and of smallwebwaf's own, which cannot be switched off" + "the Core Rule Set up and of sww's own, which cannot be switched off" ) for _, tc := range []struct{ name, value, want string }{ @@ -630,10 +630,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) { wafBodyLimit, "128KB", `"128KB" is not a size such as 512K, 100M or 5G, or off`, }, - {wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`}, + {wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` + + `gets of a body`}, { wafBodyLimit, "65537", - `"65537" is more than 64K, the most servewaf gets of a body`, + `"65537" is more than 64K, the most discriminator gets of a body`, }, } { t.Run(tc.name+"="+tc.value, func(t *testing.T) { @@ -859,7 +860,7 @@ func TestAlertSettingsAsSet(t *testing.T) { const ( webhook = "https://alerts.example:8443/hooks/waf?team=ops" slack = "https://hooks.slack.example/services/T0123/B4567/abcdef" - ntfy = "https://ntfy.example/smallwebwaf-alerts" + ntfy = "https://ntfy.example/sww-alerts" token = "tk_0123456789abcdefghijklmnopq" ) @@ -907,17 +908,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) { t.Parallel() wantStartStopped(t, []struct{ name, value string }{ - {alertWebhookURL, "alerts.example/smallwebwaf"}, + {alertWebhookURL, "alerts.example/sww"}, {alertWebhookURL, "ftp://alerts.example/"}, - {alertWebhookURL, "https:///smallwebwaf"}, + {alertWebhookURL, "https:///sww"}, {alertWebhookURL, "https://user:password@alerts.example/"}, {alertWebhookURL, "https://alerts.example/#top"}, {alertWebhookURL, "https://alerts.example:0/"}, {alertWebhookURL, "https://alerts.example:65536/"}, {alertSlackWebhookURL, "hooks.slack.example/services/T0123"}, {alertSlackWebhookURL, "https://user:password@hooks.slack.example/"}, - {alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"}, - {alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"}, + {alertNtfyURL, "ntfy://ntfy.example/sww-alerts"}, + {alertNtfyURL, "https://ntfy.example/sww-alerts#top"}, {alertWebhookHeaders, "Authorization"}, {alertWebhookHeaders, "X Team:ops"}, {alertWebhookHeaders, ":ops"}, @@ -997,7 +998,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) { _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) want := alertWebhookURL + ": is not an http or https URL without a user or " + - "a fragment, such as https://alerts.example/smallwebwaf" + "a fragment, such as https://alerts.example/sww" if err == nil || err.Error() != want { t.Errorf("error %v, want %s", err, want) } @@ -1011,7 +1012,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) { cfg := fromEnvironment(t, environment{ alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef", - alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", + alertNtfyURL: "https://ntfy.example/sww-alerts", alertNtfyToken: token, }) @@ -1030,7 +1031,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) { } } - for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} { + for _, secret := range []string{"T0123", "sww-alerts", token} { if strings.Contains(logged, secret) { t.Errorf("%s in the settings logged: %s", secret, logged) } @@ -1061,7 +1062,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin const name = "fsn1app1\r" _, err := config.FromEnvironment(environment{ - instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", + instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts", }.lookupEnv) want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + @@ -1203,7 +1204,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) { } // Set empty, the lists need nothing looked up, and nor does - // SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly + // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly // threshold per AS number that is off, any other anomaly threshold, or // a blocklist. env := environment{ @@ -1441,7 +1442,7 @@ const ( asnURL = "https://lists.example/asn.txt" ) -// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them. +// The actions of SWW_BLOCKLIST_ACTION, as Config gives them. const ( actionDeny = "deny" actionLimit = "limit" @@ -1537,7 +1538,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) { blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, }.lookupEnv) - want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too` + want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too` if err == nil || err.Error() != want { t.Errorf("error %v, want %s", err, want) } @@ -1840,7 +1841,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 ` + - `SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too` + `SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too` ) for _, tc := range []struct { @@ -2042,7 +2043,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, - {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, + {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateCounterInterval, off}, {stateCounterInterval, "15"}, }) @@ -2313,7 +2314,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { attackBanDuration: "7d", maxBans: "5000", banScopeV4Prefix: "32", - stateDir: "/var/lib/smallwebwaf", + stateDir: "/var/lib/sww", stateWriteDelay: "10s", stateCounterInterval: "15m", adminToken: "", @@ -2322,7 +2323,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { instanceName: hostname, logRequestHeaders: defaultLogRequestHeaders, logLevel: "info", - rulesDir: "/etc/smallwebwaf/rules.d", + rulesDir: "/etc/sww/rules.d", rulesEnabled: "true", wafMode: config.WAFModeBlock, wafParanoiaLevel: "1", diff --git a/internal/config/rproxy.go b/internal/config/rproxy.go index eb078ec..3f2988c 100644 --- a/internal/config/rproxy.go +++ b/internal/config/rproxy.go @@ -12,7 +12,7 @@ import ( "time" ) -// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero +// 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). @@ -22,12 +22,12 @@ type RProxy struct { // TrustedProxies are the netblocks whose X-Forwarded-For is believed // (SWRP_TRUSTED_PROXIES). TrustedProxies []netip.Prefix - // WAFBackend is servewaf's URL (SWRP_WAF_BACKEND). + // WAFBackend is discriminator's URL (SWRP_WAF_BACKEND). WAFBackend *url.URL - // Timeout is how long rproxy waits for servewaf's answer to a question, + // Timeout is how long rproxy waits for discriminator's answer to a question, // and to a report (SWRP_TIMEOUT_MS). It cannot be off. Timeout time.Duration - // DefaultDisposition is what is done with a request when servewaf cannot be + // 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). DefaultDisposition string @@ -50,7 +50,7 @@ type RProxy struct { var ( errNeedsWAFBackend = errors.New( - "is required: it is servewaf's URL, such as http://servewaf:8080") + "is required: it is discriminator's URL, such as http://discriminator:8080") errNotDisposition = errors.New("is not allow or deny") errNotMilliseconds = errors.New( "is not a whole number of milliseconds above zero, such as 2000") @@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value { return slog.GroupValue(c.settings...) } -// wafBackend reads the setting that is servewaf's URL, which must be set. +// wafBackend reads the setting that is discriminator's URL, which must be set. func (e *environment) wafBackend(name string) *url.URL { value := e.value(name, "") if value == "" { @@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL { return backend } -// disposition reads the setting that is what is done with a request when servewaf +// disposition reads the setting that is what is done with a request when discriminator // cannot be connected to, does not answer at all, or does not answer in time: // allow or deny. func (e *environment) disposition(name, defaultValue string) string { diff --git a/internal/config/rproxy_test.go b/internal/config/rproxy_test.go index d7e74f4..ae72bf5 100644 --- a/internal/config/rproxy_test.go +++ b/internal/config/rproxy_test.go @@ -24,7 +24,7 @@ const ( rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" - backendURL = "http://servewaf:8080" + backendURL = "http://discriminator:8080" rproxyDefaultAddr = ":8080" rproxyDefaultApp = "http://127.0.0.1:8081" notASize = "a lot" @@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) { name, value string }{ {wafBackend, ""}, - {wafBackend, "servewaf:8080"}, - {wafBackend, "http://servewaf:8080/path"}, + {wafBackend, "discriminator:8080"}, + {wafBackend, "http://discriminator:8080/path"}, {rproxyTimeout, "0"}, {rproxyTimeout, "-1"}, {rproxyTimeout, off}, diff --git a/internal/servewaf/admin.go b/internal/discriminator/admin.go similarity index 97% rename from internal/servewaf/admin.go rename to internal/discriminator/admin.go index c77c321..326e055 100644 --- a/internal/servewaf/admin.go +++ b/internal/discriminator/admin.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "bytes" @@ -49,9 +49,9 @@ func (rq *request) answerHealth() { _, _ = io.WriteString(rq.out, "ok\n") } -// answerAdmin answers a request for one of servewaf's own endpoints. Each +// answerAdmin answers a request for one of discriminator's own endpoints. Each // needs a token, sent as Authorization: Bearer : the metrics -// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is +// SWW_METRICS_TOKEN, the others SWW_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() { @@ -250,7 +250,7 @@ type clientAnswer struct { Bans []state.BanEntry `json:"bans"` } -// showClient answers GET ClientsPath with what servewaf knows of the client: +// showClient answers GET ClientsPath with what discriminator knows of the client: // its counters, its history, which holds its country as last looked up and its // offences, and its bans with their notes. func (rq *request) showClient() { diff --git a/internal/servewaf/admin_test.go b/internal/discriminator/admin_test.go similarity index 84% rename from internal/servewaf/admin_test.go rename to internal/discriminator/admin_test.go index 0aea41e..0e61f25 100644 --- a/internal/servewaf/admin_test.go +++ b/internal/discriminator/admin_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "encoding/json" @@ -10,15 +10,15 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/state" ) const ( - adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name - // adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer + adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name + // adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer // how a request carries it. adminSecret = "fedcba9876543210fedcba9876543210" adminBearer = "Bearer " + adminSecret @@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) { } } - // Any other request under /_smallwebwaf/ is not found. + // Any other request under /_sww/ is not found. for _, e := range []adminEndpoint{ - {http.MethodPut, servewaf.BansPath, banOtherClient}, - {http.MethodDelete, servewaf.BansPath, ""}, - {http.MethodGet, servewaf.BansPath + "/" + otherClient, ""}, - {http.MethodPost, servewaf.ClientsPath + otherClient, ""}, - {http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""}, + {http.MethodPut, discriminator.BansPath, banOtherClient}, + {http.MethodDelete, discriminator.BansPath, ""}, + {http.MethodGet, discriminator.BansPath + "/" + otherClient, ""}, + {http.MethodPost, discriminator.ClientsPath + otherClient, ""}, + {http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""}, } { s.admin(e.method, e.path, e.body, http.StatusNotFound) } @@ -118,8 +118,8 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { Reason: "probes for logins", } - wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK), - want) + wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, + http.StatusOK), want) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) if line.BanExpires != requestlog.FormatTime(expires) { @@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { // Its notes count the request it refused. want.Notes.Requests, want.Notes.Refused = 1, 1 - wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want) + wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want) // Ten minutes on, lifting the bans on client lifts that one, which is // kept, marked lifted. @@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { lifted := clk.Now() want.Lifted = &lifted - wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", + wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusOK), want) s.get(client, http.StatusOK, requestlog.ActionForward) - wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want) + wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want) // No ban on it is active any more. - s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound) + s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound) } func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { @@ -181,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { want.Expires = &expires } - wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want) + wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK), + want) } } @@ -250,7 +251,8 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) { "request body too large", }, } { - got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest) + got := s.admin(http.MethodPost, discriminator.BansPath, tc.body, + http.StatusBadRequest) if !strings.Contains(string(got.body), tc.want) { t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want) } @@ -283,7 +285,8 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { Bans []state.BanEntry `json:"bans"` } - decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got) + decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "", + http.StatusOK), &got) if got.Client == nil { t.Fatalf("%s: no client", addr) @@ -305,13 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { // Of an address no request came from and no ban covers, nothing is // known. - got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK) + got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "", + http.StatusOK) if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" { t.Errorf("an unknown client is answered\n%s", got.body) } - s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest) - s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "", + s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "", + http.StatusBadRequest) + s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "", http.StatusBadRequest) } @@ -332,24 +337,24 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) { } } -// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens. +// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens. type adminEndpoint struct { method, path, body string } -// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN +// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN // opens: listing the bans, banning otherClient for an hour, lifting the // bans on otherClient, and asking about otherClient. func adminEndpoints() []adminEndpoint { return []adminEndpoint{ - {http.MethodGet, servewaf.BansPath, ""}, - {http.MethodPost, servewaf.BansPath, banOtherClient}, - {http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""}, - {http.MethodGet, servewaf.ClientsPath + otherClient, ""}, + {http.MethodGet, discriminator.BansPath, ""}, + {http.MethodPost, discriminator.BansPath, banOtherClient}, + {http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""}, + {http.MethodGet, discriminator.ClientsPath + otherClient, ""}, } } -// admin sends a request with method for path, with body, to servewaf's own +// admin sends a request with method for path, with body, to discriminator's own // address, with the admin token, and checks that it is answered with status, its // log line's action admin. It returns the answer. func (s *sender) admin(method, path, body string, status int) answer { @@ -365,7 +370,7 @@ func (s *sender) adminRequest( ) answer { s.t.Helper() - req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body)) + req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body)) if authorization != "" { req.Header.Set("Authorization", authorization) } diff --git a/internal/servewaf/alerts_test.go b/internal/discriminator/alerts_test.go similarity index 95% rename from internal/servewaf/alerts_test.go rename to internal/discriminator/alerts_test.go index 288c17e..5dbdf6b 100644 --- a/internal/servewaf/alerts_test.go +++ b/internal/discriminator/alerts_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "maps" @@ -10,13 +10,13 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( - alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" - alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" + alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" + alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" // alertInstance is the instance every alert of these tests gives. alertInstance = "fsn1app1/blog" ) @@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) { // never sent them, and returns the queue they wait in as well. func startWithAlerts( t *testing.T, env map[string]string, -) (*sender, *clock, *servewaf.Server, *alerts.Queue) { +) (*sender, *clock, *discriminator.Server, *alerts.Queue) { t.Helper() return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env) @@ -210,21 +210,21 @@ func startWithAlerts( // startAppWithAlerts is startWithAlerts in front of the app handler. func startAppWithAlerts( t *testing.T, handler http.HandlerFunc, env map[string]string, -) (*sender, *clock, *servewaf.Server, *alerts.Queue) { +) (*sender, *clock, *discriminator.Server, *alerts.Queue) { t.Helper() app := startApp(t, handler) clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} settings := map[string]string{ trustedProxies: trustLocalhost, - alertWebhookURL: "https://alerts.example/smallwebwaf", + alertWebhookURL: "https://alerts.example/sww", instanceName: alertInstance, } maps.Copy(settings, env) addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) - s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out} + s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out} return s, clk, server, queue } diff --git a/internal/servewaf/anomaly_internal_test.go b/internal/discriminator/anomaly_internal_test.go similarity index 97% rename from internal/servewaf/anomaly_internal_test.go rename to internal/discriminator/anomaly_internal_test.go index 8f70c3a..03b4759 100644 --- a/internal/servewaf/anomaly_internal_test.go +++ b/internal/discriminator/anomaly_internal_test.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "net/netip" diff --git a/internal/servewaf/anomaly_test.go b/internal/discriminator/anomaly_test.go similarity index 94% rename from internal/servewaf/anomaly_test.go rename to internal/discriminator/anomaly_test.go index a04941f..7ea5066 100644 --- a/internal/servewaf/anomaly_test.go +++ b/internal/discriminator/anomaly_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "maps" @@ -13,19 +13,19 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // The anomaly thresholds: the prefix of a scope followed by the end of a // count. const ( - anomalyClient = "SWWAF_ANOMALY_CLIENT_" - anomalyNet = "SWWAF_ANOMALY_NET_" - anomalyASN = "SWWAF_ANOMALY_ASN_" - anomalyTotal = "SWWAF_ANOMALY_TOTAL_" - anomalyWatch = "SWWAF_WATCH_" + anomalyClient = "SWW_ANOMALY_CLIENT_" + anomalyNet = "SWW_ANOMALY_NET_" + anomalyASN = "SWW_ANOMALY_ASN_" + anomalyTotal = "SWW_ANOMALY_TOTAL_" + anomalyWatch = "SWW_WATCH_" requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerHour = "REQUESTS_PER_HOUR" @@ -35,9 +35,9 @@ const ( // The other anomaly settings. const ( - anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" - anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" - watchNets = "SWWAF_WATCH_NETS" + anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX" + anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" + watchNets = "SWW_WATCH_NETS" ) const ( @@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS - exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS - denied = "192.0.2.20" // in SWWAF_DENY_NETS + 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 ) s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ @@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) { // kept. geojsURL, _, release := startHeldGeoJS(t) - var server atomic.Pointer[servewaf.Server] + var server atomic.Pointer[discriminator.Server] app := startApp(t, func(http.ResponseWriter, *http.Request) { release() diff --git a/internal/servewaf/answertime_test.go b/internal/discriminator/answertime_test.go similarity index 82% rename from internal/servewaf/answertime_test.go rename to internal/discriminator/answertime_test.go index 7f47f8f..46d69ac 100644 --- a/internal/servewaf/answertime_test.go +++ b/internal/discriminator/answertime_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "fmt" @@ -15,25 +15,25 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( // answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time - // servewaf has to answer. + // discriminator has to answer. answerTimeLimit = 2 * time.Second - // defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT. + // defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT. defaultLookupWait = 800 * time.Millisecond // The header limits SPEC.md gives. cookieLimit = 50 headerFieldLimit = 100 // cookieHeader is the header the cookie limit counts. cookieHeader = "Cookie" - // tooLarge is how servewaf refuses a request over the header limits. + // tooLarge is how discriminator refuses a request over the header limits. tooLarge = http.StatusRequestHeaderFieldsTooLarge // silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in // that unansweredGeoJSURL names too. @@ -56,7 +56,7 @@ func cookies(count int) string { } // fillHeaders adds headers to a question until the checks see total header -// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's +// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's // client sends nothing else but Host, which the limit does not count, once // User-Agent is set. func fillHeaders(req *http.Request, total int) { @@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) { func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{wafMode: block}) + s := startDiscriminator(t, "", map[string]string{wafMode: block}) req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) @@ -81,7 +81,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) { t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) } - wantDisposition(t, got, tooLarge, servewaf.DispositionDeny) + wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) line := s.out.requestLine(t) wantLine(t, line, tooLarge, requestlog.ActionTooLarge) @@ -136,7 +136,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{wafMode: block}) + s := startDiscriminator(t, "", map[string]string{wafMode: block}) req := s.question(siteA, client, "/") tc.make(req) @@ -144,13 +144,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { line := s.out.requestLine(t) if tc.refused { - wantDisposition(t, got, tooLarge, servewaf.DispositionDeny) + wantDisposition(t, got, tooLarge, discriminator.DispositionDeny) wantLine(t, line, tooLarge, requestlog.ActionTooLarge) return } - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantLine(t, line, http.StatusOK, requestlog.ActionForward) if line.DurationWAF == nil { @@ -166,16 +166,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) { for _, env := range []map[string]string{ {wafMode: off}, {wafMode: "detect"}, - {wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"}, + {wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"}, } { t.Run(fmt.Sprint(env), func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", env) + s := startDiscriminator(t, "", env) req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) - wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny) + wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny) }) } } @@ -183,17 +183,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) { func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client}) + s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client}) req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) } func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block}) + s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block}) req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) @@ -204,7 +204,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) } - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) line := s.out.requestLine(t) if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil { @@ -216,7 +216,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"}) + s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"}) ask := func() answer { req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) @@ -224,8 +224,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) { return do(t, req) } - wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny) - wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny) + wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny) lines := s.out.requestLines(t, 2) wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited) @@ -243,13 +243,13 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ trapPaths: trapPathList, rulesDir: writeRules(t, testRules), }) req := s.question(siteA, client, tc.path) req.Header.Set(cookieHeader, cookies(cookieLimit+1)) - wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny) wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action) }) } @@ -259,13 +259,13 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) { t.Parallel() geojsURL, _ := startGeoJS(t) - s := startServeWAF(t, geojsURL, map[string]string{ + s := startDiscriminator(t, geojsURL, map[string]string{ unknownLimitPercent: "0", lookupTimeout: "1h", }) req := s.question(siteA, fromDE, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) - wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny) + wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny) line := s.out.requestLine(t) wantLine(t, line, tooLarge, requestlog.ActionTooLarge) @@ -275,14 +275,14 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) { } // The client is under no ban, and GeoJS places it in a country. - wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow) } func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) { t.Parallel() geojsURL, _ := startGeoJS(t) - s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"}) + s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"}) s.server.GeoJS.Load([]lookup.Answer{{ Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512", Answered: s.clock.Now(), Used: s.clock.Now(), @@ -291,7 +291,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T req := s.question(siteA, client, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) - wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny) wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited) } @@ -299,14 +299,14 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) { t.Parallel() geojsURL, _ := startGeoJS(t) - s := startServeWAF(t, geojsURL, map[string]string{ + s := startDiscriminator(t, geojsURL, map[string]string{ mode: observe, addLookupHeaders: on, lookupTimeout: "1h", }) req := s.question(siteA, fromDE, "/") req.Header.Set(cookieHeader, cookies(cookieLimit+1)) got := do(t, req) - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") if asn != asnDE || country != "DE" { @@ -315,18 +315,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) { } } -// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never +// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never // answers, a setting needs its answer, and everything is at its default but // for env. No request to it may use the network. -func newServeWAFInBubble( +func newDiscriminatorInBubble( t *testing.T, env map[string]string, -) (*servewaf.Server, *output) { +) (*discriminator.Server, *output) { t.Helper() settings := map[string]string{deniedCountries: "KP"} maps.Copy(settings, env) - server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings) + server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings) return server, out } @@ -334,13 +334,13 @@ func newServeWAFInBubble( // askDirectly hands a question about a request from client straight to server, // as change leaves it, and returns the answer's status and how long it took. func askDirectly( - t *testing.T, server *servewaf.Server, change func(*http.Request), + t *testing.T, server *discriminator.Server, change func(*http.Request), ) (int, time.Duration) { t.Helper() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234") - req.Header.Set(servewaf.ClientHeader, client) + req.Header.Set(discriminator.ClientHeader, client) req.Header.Set(requestIDHeader, "the-id") change(req) @@ -353,17 +353,17 @@ func askDirectly( } // banClient bans client at now. -func banClient(_ *testing.T, server *servewaf.Server, now time.Time) { +func banClient(_ *testing.T, server *discriminator.Server, now time.Time) { server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{}) } // countClient counts one request from client at now for the rate limits. -func countClient(_ *testing.T, server *servewaf.Server, now time.Time) { +func countClient(_ *testing.T, server *discriminator.Server, now time.Time) { server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit) } // listClient puts client on the blocklist at dropURL. -func listClient(t *testing.T, server *servewaf.Server, now time.Time) { +func listClient(t *testing.T, server *discriminator.Server, now time.Time) { t.Helper() err := server.Lists.Load([]reputation.List{{ @@ -375,13 +375,13 @@ func listClient(t *testing.T, server *servewaf.Server, now time.Time) { } // decideOnClient puts client on the CrowdSec decision list. -func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) { +func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) { t.Helper() loadDecisions(t, server, now, clientsDecision) } // leaveAsItIs prepares nothing. -func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {} +func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {} func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { t.Parallel() @@ -392,14 +392,14 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { name string env map[string]string // prepare gets the server ready, at now, before the question. - prepare func(t *testing.T, server *servewaf.Server, now time.Time) + prepare func(t *testing.T, server *discriminator.Server, now time.Time) // cookie is the request's Cookie header. cookie string status int action string }{ { - name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client}, + name: "SWW_DENY_NETS", env: map[string]string{denyNets: client}, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied, }, { @@ -435,7 +435,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // says, so that a request that does not wait takes no time at all. synctest.Test(t, func(t *testing.T) { - server, out := newServeWAFInBubble(t, tc.env) + server, out := newDiscriminatorInBubble(t, tc.env) tc.prepare(t, server, time.Now()) status, took := askDirectly(t, server, func(req *http.Request) { @@ -463,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) { name string env map[string]string // prepare gets the server ready, at now, before the question. - prepare func(t *testing.T, server *servewaf.Server, now time.Time) + prepare func(t *testing.T, server *discriminator.Server, now time.Time) status int action string }{ @@ -492,7 +492,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { - server, out := newServeWAFInBubble(t, tc.env) + server, out := newDiscriminatorInBubble(t, tc.env) tc.prepare(t, server, time.Now()) status, took := askDirectly(t, server, func(*http.Request) {}) @@ -508,7 +508,9 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) { } } -func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) { +func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs( + t *testing.T, +) { t.Parallel() held, askedGeoJS, _ := startHeldGeoJS(t) @@ -521,10 +523,12 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin t.Cleanup(func() { _ = resolver.Close() }) - server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server { + server, out, _ := newDiscriminatorWith(t, func( + params discriminator.Params, + ) *discriminator.Server { params.AbuseIPDBURL = silentAbuseIPDBURL - return servewaf.New(params) + return discriminator.New(params) }, held, time.Now, map[string]string{ deniedCountries: "KP", wafMode: block, dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(), diff --git a/internal/servewaf/bans.go b/internal/discriminator/bans.go similarity index 94% rename from internal/servewaf/bans.go rename to internal/discriminator/bans.go index 44d5996..913fee3 100644 --- a/internal/servewaf/bans.go +++ b/internal/discriminator/bans.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "net/http" @@ -12,7 +12,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged +// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged // with action. func (rq *request) banResponse(action string) *refusal { return &refusal{status: rq.h.config.BanResponse, action: action} @@ -83,10 +83,10 @@ func (rq *request) countBytes() { } // countRefusal counts the request for the error burst once it has been -// answered, if servewaf refused it after a rule file match, a trap path or a +// 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 -// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute -// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is +// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute +// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is // not counted, and nothing is while the threshold is off. func (rq *request) countRefusal() { cfg := rq.h.config @@ -121,7 +121,7 @@ func (rq *request) countRefusal() { // 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 SWWAF_BYTES_COUNT says. +// bytes, the request's, or both, as SWW_BYTES_COUNT says. func (rq *request) countedBytes() int64 { response, request := rq.out.bytes, rq.requestBytes @@ -138,7 +138,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: -// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for +// SWW_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 @@ -277,7 +277,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) { // 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 -// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the +// SWW_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, @@ -343,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 { } // netblock is the netblock a ban on client covers: its IPv4 address, -// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup +// widened to SWW_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/servewaf/bans_test.go b/internal/discriminator/bans_test.go similarity index 90% rename from internal/servewaf/bans_test.go rename to internal/discriminator/bans_test.go index 2ca896a..4c27c6a 100644 --- a/internal/servewaf/bans_test.go +++ b/internal/discriminator/bans_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "bufio" @@ -15,8 +15,8 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( @@ -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 SWWAF_BAN_RESPONSE that closes the connection. + // banResponseClose is the SWW_BAN_RESPONSE that closes the connection. banResponseClose = "close" ) @@ -89,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) { } } - // A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within - // SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. + // A first ban lasts SWW_LIMIT_BAN_DURATION; one within + // SWW_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, SWWAF_LIMIT_BAN_DURATION again. + // Later than that, SWW_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 SWWAF_MAX_BAN_DURATION: the ban is + // 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is // permanent. clk.advance(30 * time.Minute) @@ -148,8 +148,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 SWWAF_ALLOW_NETS - exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS + allowed = "203.0.113.60" // in SWW_ALLOW_NETS + exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS ) for _, tc := range []struct { @@ -164,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { nil, []string{otherClient, exempt}, }, { - "the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets", + "the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets", map[string]string{banScopeV4Prefix: "24"}, client, []string{otherClient, exempt}, []string{"203.0.112.9", allowed}, }, @@ -173,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) { []string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"}, }, { - "the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets", + "the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets", map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1", []string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"}, }, @@ -231,10 +231,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWWAF_DENY_NETS + const denied = "192.0.2.50" // in SWW_DENY_NETS for _, tc := range []struct { - setting string // "" leaves SWWAF_BAN_RESPONSE at its default + setting string // "" leaves SWW_BAN_RESPONSE at its default status int // 0 is the connection closed without an answer }{ {"", http.StatusForbidden}, @@ -352,7 +352,7 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) { s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) } -// clock is the time a test sets, by which smallwebwaf counts requests and +// clock is the time a test sets, by which sww counts requests and // makes bans. type clock struct { mu sync.Mutex @@ -375,14 +375,14 @@ func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) } -// startWithClock starts servewaf, and rproxy in front of an app that answers 200, +// startWithClock starts discriminator, and rproxy in front of an app that answers 200, // with the settings in env on top of trusting localhost's // X-Forwarded-For, clients' AS numbers and countries looked up at // geojsURL, and a clock set to midnight, the start of a bucket in every // window. func startWithClock( t *testing.T, geojsURL string, env map[string]string, -) (*sender, *clock, *servewaf.Server) { +) (*sender, *clock, *discriminator.Server) { t.Helper() app := startApp(t, func(http.ResponseWriter, *http.Request) {}) @@ -392,19 +392,20 @@ func startWithClock( addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings) - return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server + return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server } // sender sends requests to rproxy one after another, each on a connection of its -// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's -// own endpoints are, which adminRequest asks. Every request servewaf gets must -// come through the sender, since the log lines are matched to them in order. +// own, and checks each one's answer and log line. discriminatorAddr is where +// discriminator's own endpoints are, which adminRequest asks. Every request +// discriminator gets must come through the sender, since the log lines are +// matched to them in order. type sender struct { - t *testing.T - addr string - serveWAFAddr string - out *output - sent int + t *testing.T + addr string + discriminatorAddr string + out *output + sent int } // get sends a GET request for / from the client at from. diff --git a/internal/servewaf/biased.go b/internal/discriminator/biased.go similarity index 75% rename from internal/servewaf/biased.go rename to internal/discriminator/biased.go index 6554b95..c7a0654 100644 --- a/internal/servewaf/biased.go +++ b/internal/discriminator/biased.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "sneak.berlin/go/smallwebwaf/internal/config" @@ -18,7 +18,7 @@ type percentage struct { // biasedThresholdsSet reports whether a biased threshold can lower a // client's limits: one of its lists is not empty, -// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL +// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_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.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 || @@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool { // 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 SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file -// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one -// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a +// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file +// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one +// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // country, unless its request skipped the wait for GeoJS, -// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the -// percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client +// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the +// percentage of SWW_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 SWWAF_REPUTATION_ACTION while it is limit. For the byte -// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the +// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte +// limits, SWW_ASN_BYTES_PERCENT and SWW_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) { cfg := rq.h.config @@ -47,36 +47,36 @@ func (rq *request) limitPercentages() (percentage, percentage) { unknown := percentage{percent: whole} if country == "" && !rq.skippedLookupWait { - unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"} + unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"} } fetched := percentage{percent: whole} if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed { - fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"} + fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"} } blocklisted := percentage{percent: whole} if rq.blocklisted && cfg.BlocklistAction == "limit" { - blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"} + blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"} } reputationListed := percentage{percent: whole} if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" { - reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"} + reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"} } - asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"), + asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"), fetched) countryRequests := given(cfg.CountryLimitPercent, country, - "SWWAF_COUNTRY_LIMIT_PERCENT") + "SWW_COUNTRY_LIMIT_PERCENT") asnBytes, countryBytes := asnRequests, countryRequests if _, listed := cfg.ASNBytesPercent[asn]; listed { - asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT") + asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT") } if _, listed := cfg.CountryBytesPercent[country]; listed { - countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT") + countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT") } return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed), diff --git a/internal/servewaf/biased_test.go b/internal/discriminator/biased_test.go similarity index 94% rename from internal/servewaf/biased_test.go rename to internal/discriminator/biased_test.go index 3c4462b..e9cb48d 100644 --- a/internal/servewaf/biased_test.go +++ b/internal/discriminator/biased_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "fmt" @@ -15,18 +15,18 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // The biased thresholds. const ( - asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" - countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" - asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" - countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" - unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" + asnLimitPercent = "SWW_ASN_LIMIT_PERCENT" + countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT" + asnBytesPercent = "SWW_ASN_BYTES_PERCENT" + countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT" + unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT" ) const ( @@ -230,7 +230,7 @@ func TestLowestPercentageApplies(t *testing.T) { noCountry, "25 from " + unknownLimitPercent, }, { - // SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100. + // SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100. "the AS number's, for a client without a country", map[string]string{asnLimitPercent: "AS64500:25"}, noCountry, "25 from " + asnLimitPercent, @@ -280,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) { // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // says, with a GeoJS that never answers. synctest.Test(t, func(t *testing.T) { - server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, + server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, map[string]string{unknownLimitPercent: "0"}) // Once the time the request waits for its answer is up, the client @@ -319,7 +319,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) { // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // says, with a GeoJS that never answers. synctest.Test(t, func(t *testing.T) { - server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, + server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value}) began := time.Now() @@ -419,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T // clock. func startWithLookups( t *testing.T, env map[string]string, -) (*sender, *servewaf.Server, *alerts.Queue) { +) (*sender, *discriminator.Server, *alerts.Queue) { t.Helper() s, _, server, queue := startWithLookupsAndClock(t, env) @@ -434,7 +434,7 @@ func startWithLookups( // country, and no other address. func startWithLookupsAndClock( t *testing.T, env map[string]string, -) (*sender, *clock, *servewaf.Server, *alerts.Queue) { +) (*sender, *clock, *discriminator.Server, *alerts.Queue) { t.Helper() path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") @@ -463,11 +463,13 @@ func (s *sender) uploadFrom(from string) logLine { // serveFromDE hands a question about a request from fromDE with method and body // straight to server's handler, without the network, and returns once it is // answered. -func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) { +func serveFromDE( + t *testing.T, server *discriminator.Server, method string, body io.Reader, +) { t.Helper() req := httptest.NewRequestWithContext(t.Context(), method, "/", body) - req.Header.Set(servewaf.ClientHeader, fromDE) + req.Header.Set(discriminator.ClientHeader, fromDE) req.Header.Set(requestIDHeader, "the-id") server.Handler.ServeHTTP(httptest.NewRecorder(), req) @@ -485,7 +487,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 SWWAF_ASN_LIMIT_PERCENT", or none when both are +// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are // left out. func percentText(percent *int64, setting string) string { switch { diff --git a/internal/servewaf/bytelimits_test.go b/internal/discriminator/bytelimits_test.go similarity index 96% rename from internal/servewaf/bytelimits_test.go rename to internal/discriminator/bytelimits_test.go index 37b1370..e722240 100644 --- a/internal/servewaf/bytelimits_test.go +++ b/internal/discriminator/bytelimits_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "bufio" @@ -19,13 +19,13 @@ import ( // The byte limit settings. const ( - bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" - bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" - bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" - bytesCount = "SWWAF_BYTES_COUNT" + bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE" + bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR" + bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY" + bytesCount = "SWW_BYTES_COUNT" ) -// The values of SWWAF_BYTES_COUNT. +// The values of SWW_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 SWWAF_BYTES_LIMIT_PER_MINUTE. + // minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE. minuteBytes = "minute_bytes" ) @@ -92,7 +92,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) { s.get(client, http.StatusForbidden, requestlog.ActionBanned) - wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+ + wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+ `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) }) } @@ -212,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS - exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.7" // in SWW_ALLOW_NETS + exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS ) s, _ := startWithAnswers(t, map[string]string{ diff --git a/internal/servewaf/client.go b/internal/discriminator/client.go similarity index 94% rename from internal/servewaf/client.go rename to internal/discriminator/client.go index a82ad1f..4f72d24 100644 --- a/internal/servewaf/client.go +++ b/internal/discriminator/client.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "crypto/rand" @@ -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 SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser +// length SWW_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/servewaf/coreruleset.go b/internal/discriminator/coreruleset.go similarity index 88% rename from internal/servewaf/coreruleset.go rename to internal/discriminator/coreruleset.go index 75a382f..94ecb98 100644 --- a/internal/servewaf/coreruleset.go +++ b/internal/discriminator/coreruleset.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "time" @@ -10,10 +10,10 @@ import ( ) // checkCoreRuleSet inspects the request with the Core Rule Set, unless -// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as +// 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 -// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, +// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // and in block mode refuses the request, which is an offence its client's // history counts, and so returns ActionWAFBlocked. It returns "" for a // request it does not refuse. @@ -55,7 +55,7 @@ func (rq *request) checkCoreRuleSet() string { } // alertWAFBlock raises the waf_block alert for the request, which the Core -// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its +// 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. @@ -82,7 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) { ASN: rq.line.ASN, ASName: rq.line.ASName, Country: rq.line.Country, - Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", + Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", Detail: detail, }) } diff --git a/internal/servewaf/coreruleset_test.go b/internal/discriminator/coreruleset_test.go similarity index 93% rename from internal/servewaf/coreruleset_test.go rename to internal/discriminator/coreruleset_test.go index bab2605..3e6921b 100644 --- a/internal/servewaf/coreruleset_test.go +++ b/internal/discriminator/coreruleset_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "io" @@ -14,13 +14,13 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and +// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and // its two modes that inspect requests. const ( - wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" - wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" - wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" - wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" + wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD" + wafDisabledRules = "SWW_WAF_DISABLED_RULES" + wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" + wafBodyLimit = "SWW_WAF_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 SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a +// sizeLimit is SWW_WAF_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 -// SWWAF_WAF_ANOMALY_THRESHOLD. +// SWW_WAF_ANOMALY_THRESHOLD. const sqlInjection = "/?id=1'%20OR%20'1'='1" // wantWAF checks the request log line's waf_rule_ids and waf_score, and @@ -125,7 +125,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( ) { t.Parallel() - const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS + const allowed = "192.0.2.60" // in SWW_ALLOW_NETS s, _, _ := startWithClock(t, "", map[string]string{ wafMode: block, @@ -134,7 +134,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( rulesDir: writeRules(t, testRules), }) - // A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS + // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS // exempts, are not inspected. line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) wantWAF(t, line, nil) @@ -153,7 +153,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( requestlog.ActionRuleBlocked) wantWAF(t, line, nil) - // With SWWAF_WAF_MODE off, no request is. + // With SWW_WAF_MODE off, no request is. s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) wantWAF(t, line, nil) @@ -367,10 +367,10 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) metrics := s.scrape() wantMetric(t, metrics, - `smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) + `sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) wantMetric(t, metrics, - `smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3) - wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+ + `sww_offences_total{instance="app",kind="waf_blocked"}`, 3) + wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+ `instance="app",status_class="4xx"}`, 3) } @@ -394,9 +394,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T metrics := s.scrape() wantMetric(t, metrics, - `smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) + `sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) wantNoSeries(t, metrics, - `smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`) + `sww_offences_total{instance="app",kind="waf_blocked"}`) } func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { @@ -468,7 +468,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) { Host: appHost, Client: netip.MustParseAddr(client), Netblock: netip.MustParsePrefix(client + "/32"), - Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", + Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD", Detail: detail, }) diff --git a/internal/servewaf/countries.go b/internal/discriminator/countries.go similarity index 89% rename from internal/servewaf/countries.go rename to internal/discriminator/countries.go index fdea122..e2e1887 100644 --- a/internal/servewaf/countries.go +++ b/internal/discriminator/countries.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "slices" @@ -7,7 +7,7 @@ import ( // countryDenied reports whether the country lists refuse the request, by // the client's country as it was looked up. A client without a country, // or whose country cannot be found, is refused only by -// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. +// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. func (rq *request) countryDenied() bool { denied := rq.h.config.DeniedCountries allowed := rq.h.config.ExclusivelyAllowedCountries diff --git a/internal/servewaf/countries_test.go b/internal/discriminator/countries_test.go similarity index 98% rename from internal/servewaf/countries_test.go rename to internal/discriminator/countries_test.go index edf30f7..2c343b6 100644 --- a/internal/servewaf/countries_test.go +++ b/internal/discriminator/countries_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "encoding/json" @@ -245,11 +245,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) { action string }{ { - "not in SWWAF_ALLOW_NETS", "", + "not in SWW_ALLOW_NETS", "", http.StatusForbidden, requestlog.ActionCountryDenied, }, { - "in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8", + "in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8", http.StatusOK, requestlog.ActionForward, }, } { @@ -279,7 +279,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 SWWAF_LOOKUP_TIMEOUT to an +// the stand-in to be asked or to answer sets SWW_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. @@ -350,7 +350,7 @@ const ( ) // geojsAnswer is an answer of GeoJS about one address, with the fields -// smallwebwaf reads. +// sww reads. // //nolint:tagliatelle // GeoJS's own names type geojsAnswer struct { diff --git a/internal/servewaf/crowdsec_test.go b/internal/discriminator/crowdsec_test.go similarity index 92% rename from internal/servewaf/crowdsec_test.go rename to internal/discriminator/crowdsec_test.go index 83fc694..847a632 100644 --- a/internal/servewaf/crowdsec_test.go +++ b/internal/discriminator/crowdsec_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -9,17 +9,17 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // The CrowdSec settings, and the tests' engine, which is never asked: each // test puts in the copy of its decision list, at decisionsURL, that it // needs, as reputation.json would at start. const ( - crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" - crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" + crowdSecURL = "SWW_CROWDSEC_LAPI_URL" + crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" lapi = "http://crowdsec.example:8080" decisionsURL = lapi + "/v1/decisions" bouncerKey = "crowdsec-key-0123456789abcdef" @@ -87,11 +87,11 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi metrics := s.scrape() labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}` - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+ + wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+ `instance="`+alertInstance+`"}`, 1) - wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1) - wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, + wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1) + wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels, float64(fetched.Unix())) } @@ -168,7 +168,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan( // decisionsURL, answer, the engine's answer, fetched at fetched, as // reputation.json would at start. func loadDecisions( - t *testing.T, server *servewaf.Server, fetched time.Time, answer string, + t *testing.T, server *discriminator.Server, fetched time.Time, answer string, ) { t.Helper() diff --git a/internal/servewaf/servewaf.go b/internal/discriminator/discriminator.go similarity index 86% rename from internal/servewaf/servewaf.go rename to internal/discriminator/discriminator.go index 5ffa461..ea3b216 100644 --- a/internal/servewaf/servewaf.go +++ b/internal/discriminator/discriminator.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "bytes" @@ -17,18 +17,18 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// The headers of rproxy's question and of servewaf's answer, as -// "servewaf" in SPEC.md gives them. +// The headers of rproxy's question and of discriminator's answer, as +// "discriminator" in SPEC.md gives them. const ( // ClientHeader marks a request as rproxy's question, and gives the // visitor's address. - ClientHeader = "X-Smallwebwaf-Client" + ClientHeader = "X-Sww-Client" // PeerHeader gives the address of rproxy's own TCP peer. - PeerHeader = "X-Smallwebwaf-Peer" + PeerHeader = "X-Sww-Peer" // ContentLengthHeader gives the body length the visitor announced. - ContentLengthHeader = "X-Smallwebwaf-Content-Length" - // DispositionHeader gives servewaf's answer. - DispositionHeader = "X-Smallwebwaf-Disposition" + ContentLengthHeader = "X-Sww-Content-Length" + // DispositionHeader gives discriminator's answer. + DispositionHeader = "X-Sww-Disposition" ) // The values of DispositionHeader. @@ -39,15 +39,15 @@ const ( // headers and body. DispositionDeny = "deny" // DispositionClose tells rproxy to close the visitor's connection - // without an answer, which SWWAF_BAN_RESPONSE asks for when it is close. - // servewaf closing the question's connection instead would read as - // servewaf being down, and rproxy would then let the request through. + // without an answer, which SWW_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" ) -// ReportPath is where rproxy sends the report of each request servewaf +// ReportPath is where rproxy sends the report of each request discriminator // answered. -const ReportPath = "/_smallwebwaf/report" +const ReportPath = "/_sww/report" // QuestionBodyMaxBytes is the most of a visitor's body a question carries. const QuestionBodyMaxBytes = 64 << 10 @@ -76,11 +76,11 @@ const ( // maxReportedMilliseconds is the longest duration a report may give, // about 31 years, so that it fits a time.Duration. maxReportedMilliseconds = 1e12 - // serveWAFTimeout bounds how long a request to servewaf takes to arrive, - // and its answer to leave, and serveWAFIdleTimeout how long a kept-open - // connection waits for its next request. - serveWAFTimeout = 60 * time.Second - serveWAFIdleTimeout = 120 * time.Second + // discriminatorTimeout bounds how long a request to discriminator takes to + // arrive, and its answer to leave, and discriminatorIdleTimeout how long a + // kept-open connection waits for its next request. + discriminatorTimeout = 60 * time.Second + discriminatorIdleTimeout = 120 * time.Second ) var ( @@ -103,7 +103,7 @@ type Report struct { DurationUpstreamTotal float64 `json:"duration_upstream_total"` } -// New returns the server `smallwebwaf servewaf` runs. It answers +// New returns the server `sww discriminator` runs. It answers // rproxy's questions, takes rproxy's reports and answers its own endpoints. It // passes nothing to an app. rproxy applies the size and time limits to the // visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it @@ -117,9 +117,9 @@ func New(params Params) *Server { Addr: params.Config.ListenAddr, Handler: h, ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn), - ReadTimeout: serveWAFTimeout, - WriteTimeout: serveWAFTimeout, - IdleTimeout: serveWAFIdleTimeout, + ReadTimeout: discriminatorTimeout, + WriteTimeout: discriminatorTimeout, + IdleTimeout: discriminatorIdleTimeout, }, Ledger: h.ledger, Limiter: h.limiter, @@ -133,8 +133,9 @@ func New(params Params) *Server { } } -// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is -// rproxy's question, and any other is for servewaf itself. +// ServeHTTP handles one request to discriminator: a request carrying +// ClientHeader is rproxy's question, and any other is for discriminator +// itself. func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(r.Header.Values(ClientHeader)) > 0 { h.answerQuestion(w, r) @@ -148,9 +149,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // refusedWhateverTheAnswer reports whether the request is one that does not wait // for GeoJS, since the checks will refuse it at now whatever GeoJS answers about // its client: it is over the header limits, a blocklist lists its client while -// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is +// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is // over a whole rate limit. In observe mode, which refuses nothing, none is. -// rproxy lets a request through when servewaf answers too late. +// rproxy lets a request through when discriminator answers too late. func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { if rq.h.config.Observe { return false @@ -165,7 +166,7 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)) } -// overHeaderLimits reports whether servewaf refuses the request for its +// overHeaderLimits reports whether discriminator refuses the request for its // headers: it has more than maxHeaderFields fields, each value of a header sent // twice being one, or more than maxCookies cookies, each part of a Cookie header // between semicolons being one unless it is empty or only spaces and tabs. The @@ -212,7 +213,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { defer rq.finish() rq.site = r.Host - rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision + rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision refused := rq.check(r.Context()) rq.checked = time.Now() underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) @@ -238,7 +239,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { return } - // The app never sees this prefix. servewaf's own endpoints are at its + // The app never sees this prefix. discriminator's own endpoints are at its // own address, so this answers as a path with no endpoint does. rq.line.Action = requestlog.ActionAdmin rq.out.Header().Set(DispositionHeader, DispositionDeny) @@ -246,7 +247,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { } // readQuestion returns the visitor's address and the body a question carries, -// and whether servewaf takes the question. It answers a question it does not +// and whether discriminator takes the question. It answers a question it does not // take with 400 and no disposition, which rproxy treats as it treats no // answer. The id of a question it takes is held from here on, without a // decision yet, so that another question with that id is refused while this @@ -289,7 +290,7 @@ func (h *handler) readQuestion( held, _ := h.awaitingReport.ContainsOrAdd(id, nil) if held { - h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds") + h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds") return netip.Addr{}, nil, false } @@ -297,14 +298,15 @@ func (h *handler) readQuestion( return client, body, true } -// refuseQuestion answers a question servewaf does not take, for reason. +// refuseQuestion answers a question discriminator does not take, for reason. func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) { h.processLog.Warn("a question is refused", "reason", reason) http.Error(w, reason, http.StatusBadRequest) } // asTheVisitorSentIt returns a copy of r, a question, as the checks and the -// log line are to see it: without the headers rproxy adds for servewaf, with +// log line are to see it: without the headers rproxy adds for discriminator, +// with // body, which has been read of r, and with the length the visitor announced, // as -1 when it announced none. The question's own length covers only the part // of the body it carries. @@ -334,7 +336,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request { } // allow answers a question with DispositionAllow and, while -// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country. +// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country. func (rq *request) allow() { rq.allowed = true @@ -360,7 +362,7 @@ func (rq *request) deny(r refusal) { rq.refused = &r if r.status == 0 { - // SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's + // SWW_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) @@ -372,7 +374,7 @@ func (rq *request) deny(r refusal) { http.Error(rq.out, http.StatusText(r.status), r.status) } -// answerOwnEndpoint answers a request to servewaf's own address. No check +// answerOwnEndpoint answers a request to discriminator's own address. No check // runs on it: the address is on the private network, where the peers are // rproxy and the admin's tools, not visitors. func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) { @@ -487,7 +489,7 @@ func (h *handler) finishWith(held *decision, report Report) { requestBytes: report.RequestBytes, } - rq.line.ServeWAFLine = requestlog.ServeWAFLineReport + rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport rq.line.Status = report.Status rq.line.RequestBytes = report.RequestBytes rq.line.ResponseBytes = report.ResponseBytes diff --git a/internal/servewaf/servewaf_test.go b/internal/discriminator/discriminator_test.go similarity index 75% rename from internal/servewaf/servewaf_test.go rename to internal/discriminator/discriminator_test.go index 2c355e1..7800ea3 100644 --- a/internal/servewaf/servewaf_test.go +++ b/internal/discriminator/discriminator_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "context" @@ -19,18 +19,18 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( - // siteA and siteB are two sites behind one servewaf. + // siteA and siteB are two sites behind one discriminator. siteA = "a.example" siteB = "b.example" // rproxyPeer is what the tests' questions give as rproxy's TCP peer. rproxyPeer = "172.18.0.2" - // heldLimit is how many answered requests "servewaf" in SPEC.md says it + // heldLimit is how many answered requests "discriminator" in SPEC.md says it // holds. heldLimit = 10000 // reportedBytesLimit is the most bytes for each way SPEC.md lets a report @@ -39,10 +39,10 @@ const ( // reportBodyLimit is the longest body SPEC.md lets a report have. reportBodyLimit = 1 << 20 // anomalyClientPerMinute is the anomaly threshold these tests set. - anomalyClientPerMinute = "SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" + anomalyClientPerMinute = "SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" ) -// The fields of a report, as "servewaf" in SPEC.md names them. +// The fields of a report, as "discriminator" in SPEC.md names them. const ( reportID = "request_id" reportStatus = "status" @@ -52,43 +52,46 @@ const ( reportUpstreamDuration = "duration_upstream_total" ) -// serveWAF is a servewaf running for one test, on a clock set to midnight. -type serveWAF struct { +// Discriminator is a discriminator running for one test, on a clock set to midnight. +type Discriminator struct { t *testing.T addr string out *output - server *servewaf.Server + server *discriminator.Server clock *clock queue *alerts.Queue } -// startServeWAF starts servewaf with the settings in env, clients' AS numbers +// startDiscriminator starts discriminator with the settings in env, clients' AS numbers // and countries looked up at geojsURL. -func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF { +func startDiscriminator( + t *testing.T, geojsURL string, env map[string]string, +) *Discriminator { t.Helper() - return startServeWAFWith(t, geojsURL, env, func(*servewaf.Server) {}) + return startDiscriminatorWith(t, geojsURL, env, func(*discriminator.Server) {}) } -// startServeWAFWith is startServeWAF with change applied to the server before +// startDiscriminatorWith is startDiscriminator with change applied to the server before // it starts. -func startServeWAFWith( - t *testing.T, geojsURL string, env map[string]string, change func(*servewaf.Server), -) *serveWAF { +func startDiscriminatorWith( + t *testing.T, geojsURL string, env map[string]string, + change func(*discriminator.Server), +) *Discriminator { t.Helper() clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} settings := map[string]string{ - alertWebhookURL: "https://alerts.example/servewaf", + alertWebhookURL: "https://alerts.example/discriminator", instanceName: alertInstance, } maps.Copy(settings, env) - server, out, queue := newServeWAF(t, geojsURL, clk.Now, settings) + server, out, queue := newDiscriminator(t, geojsURL, clk.Now, settings) change(server) serve(t, server) - return &serveWAF{ + return &Discriminator{ t: t, addr: server.Addr, out: out, server: server, clock: clk, queue: queue, } } @@ -96,22 +99,22 @@ func startServeWAFWith( // questionWith makes rproxy's question about a request for path on host, from // the visitor at from, carrying body and a new id, as rproxy makes for every // request. -func (s *serveWAF) questionWith( +func (s *Discriminator) questionWith( method, host, from, path string, body io.Reader, ) *http.Request { s.t.Helper() req := newRequest(s.t, method, s.addr, path, body) req.Host = host - req.Header.Set(servewaf.ClientHeader, from) - req.Header.Set(servewaf.PeerHeader, rproxyPeer) + req.Header.Set(discriminator.ClientHeader, from) + req.Header.Set(discriminator.PeerHeader, rproxyPeer) req.Header.Set(requestIDHeader, rand.Text()) return req } // question makes rproxy's question about a request for path without a body. -func (s *serveWAF) question(host, from, path string) *http.Request { +func (s *Discriminator) question(host, from, path string) *http.Request { s.t.Helper() return s.questionWith(http.MethodGet, host, from, path, http.NoBody) @@ -119,7 +122,7 @@ func (s *serveWAF) question(host, from, path string) *http.Request { // ask sends the question about a request for path on siteA from the visitor at // from, and returns the answer. -func (s *serveWAF) ask(from, path string) answer { +func (s *Discriminator) ask(from, path string) answer { s.t.Helper() return do(s.t, s.question(siteA, from, path)) @@ -127,17 +130,17 @@ func (s *serveWAF) ask(from, path string) answer { // allowed asks about a request for / on siteA, checks that the answer is allow, // and returns the request's id. -func (s *serveWAF) allowed(from string) string { +func (s *Discriminator) allowed(from string) string { s.t.Helper() req := s.question(siteA, from, "/") - wantDisposition(s.t, do(s.t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(s.t, do(s.t, req), http.StatusOK, discriminator.DispositionAllow) return req.Header.Get(requestIDHeader) } // report sends rproxy's report and returns the answer. -func (s *serveWAF) report(report servewaf.Report) answer { +func (s *Discriminator) report(report discriminator.Report) answer { s.t.Helper() body, err := json.Marshal(report) @@ -149,19 +152,19 @@ func (s *serveWAF) report(report servewaf.Report) answer { } // reportBody sends body as rproxy's report and returns the answer. -func (s *serveWAF) reportBody(body string) answer { +func (s *Discriminator) reportBody(body string) answer { s.t.Helper() - return do(s.t, newRequest(s.t, http.MethodPost, s.addr, servewaf.ReportPath, + return do(s.t, newRequest(s.t, http.MethodPost, s.addr, discriminator.ReportPath, strings.NewReader(body))) } // passedOn asks about a request from the visitor at from on siteA, and reports // it as answered 200 with bodyBytes and answerBytes. -func (s *serveWAF) passedOn(from string) { +func (s *Discriminator) passedOn(from string) { s.t.Helper() - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: s.allowed(from), Status: http.StatusOK, RequestBytes: bodyBytes, @@ -171,18 +174,18 @@ func (s *serveWAF) passedOn(from string) { } // history returns what the table of clients holds of client's history. -func (s *serveWAF) history() ratelimit.History { +func (s *Discriminator) history() ratelimit.History { held, _ := s.server.Limiter.Client(netip.MustParsePrefix(client + "/32")) return held.History } // metrics returns what the metrics endpoint answers, for a server started with -// the token as SWWAF_METRICS_TOKEN. -func (s *serveWAF) metrics() string { +// the token as SWW_METRICS_TOKEN. +func (s *Discriminator) metrics() string { s.t.Helper() - req := newRequest(s.t, http.MethodGet, s.addr, servewaf.MetricsPath, http.NoBody) + req := newRequest(s.t, http.MethodGet, s.addr, discriminator.MetricsPath, http.NoBody) req.Header.Set("Authorization", bearer) got := do(s.t, req) @@ -196,7 +199,8 @@ func wantDisposition(t *testing.T, got answer, status int, disposition string) { t.Helper() wantStatus(t, got, status) - if answered := got.header.Get(servewaf.DispositionHeader); answered != disposition { + if answered := got.header.Get(discriminator.DispositionHeader); answered != + disposition { t.Errorf("answered with disposition %q, want %q", answered, disposition) } } @@ -212,7 +216,7 @@ func wantBody(t *testing.T, got answer, want string) { // wantProcessMessages checks that msg is among the process's messages count // times, each from the server's log at the level warnings are. -func wantProcessMessages(t *testing.T, s *serveWAF, msg string, count int) { +func wantProcessMessages(t *testing.T, s *Discriminator, msg string, count int) { t.Helper() found := 0 @@ -230,7 +234,7 @@ func wantProcessMessages(t *testing.T, s *serveWAF, msg string, count int) { } // wantNoRequestLines checks that no request log line has been written. -func wantNoRequestLines(t *testing.T, s *serveWAF) { +func wantNoRequestLines(t *testing.T, s *Discriminator) { t.Helper() for _, fields := range s.out.lines(t) { @@ -243,15 +247,15 @@ func wantNoRequestLines(t *testing.T, s *serveWAF) { func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) req := s.question(siteA, client, "/page?q=1&token=visible") req.Header.Set(requestIDHeader, "the-id") req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set(servewaf.ContentLengthHeader, "1234") + req.Header.Set(discriminator.ContentLengthHeader, "1234") got := do(t, req) - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) wantBody(t, got, "") if id := got.header.Get(requestIDHeader); id != "" { @@ -265,7 +269,7 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) { line.Path != "/page" || line.Query != "q=1&token=visible" || line.RequestID != "the-id" || line.Scheme != "https" || line.ContentLength != 1234 || - line.ServeWAFLine != requestlog.ServeWAFLineDecision { + line.DiscriminatorLine != requestlog.DiscriminatorLineDecision { t.Errorf("log line is %+v", line.Line) } } @@ -273,23 +277,23 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) { func TestIPv6VisitorIsCountedInItsGroup(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"}) + s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"}) s.allowed("2001:db8::1") got := s.ask("2001:db8::2", "/") - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) } func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{denyNets: client}) + s := startDiscriminator(t, "", map[string]string{denyNets: client}) req := s.question(siteA, "::ffff:"+client, "/") - req.Header.Set(servewaf.PeerHeader, "::ffff:"+rproxyPeer) + req.Header.Set(discriminator.PeerHeader, "::ffff:"+rproxyPeer) - wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny) line := s.out.requestLine(t) if line.ClientIP != client || line.PeerIP != rproxyPeer { @@ -305,13 +309,13 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) { change func(*http.Request) }{ {"client not an address", func(req *http.Request) { - req.Header.Set(servewaf.ClientHeader, "not an address") + req.Header.Set(discriminator.ClientHeader, "not an address") }}, {"client empty", func(req *http.Request) { - req.Header.Set(servewaf.ClientHeader, "") + req.Header.Set(discriminator.ClientHeader, "") }}, {"client address with a zone", func(req *http.Request) { - req.Header.Set(servewaf.ClientHeader, "2001:db8::1%eth0") + req.Header.Set(discriminator.ClientHeader, "2001:db8::1%eth0") }}, {"id absent", func(req *http.Request) { req.Header.Del(requestIDHeader) @@ -329,7 +333,7 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{metricsToken: token}) + s := startDiscriminator(t, "", map[string]string{metricsToken: token}) req := s.question(siteA, client, "/") tc.change(req) @@ -349,33 +353,33 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) { func TestLongestValidIDIsTaken(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) req := s.question(siteA, client, "/") req.Header.Set(requestIDHeader, strings.Repeat("x", 128)) - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) } func TestQuestionWithABodyOver64KiBGets400(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) - over := strings.NewReader(strings.Repeat("x", servewaf.QuestionBodyMaxBytes+1)) + over := strings.NewReader(strings.Repeat("x", discriminator.QuestionBodyMaxBytes+1)) got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over)) wantDisposition(t, got, http.StatusBadRequest, "") wantProcessMessages(t, s, "a question is refused", 1) wantNoRequestLines(t, s) - whole := strings.NewReader(strings.Repeat("x", servewaf.QuestionBodyMaxBytes)) + whole := strings.NewReader(strings.Repeat("x", discriminator.QuestionBodyMaxBytes)) got = do(t, s.questionWith(http.MethodPost, siteA, client, "/", whole)) - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) } func TestQuestionWithAHeldIDIsRefused(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) id := s.allowed(client) @@ -385,15 +389,15 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) { wantProcessMessages(t, s, "a question is refused", 1) // A denied request's id is held as well. - denied := s.question(siteA, otherClient, servewaf.HealthPath) - wantDisposition(t, do(t, denied), http.StatusNotFound, servewaf.DispositionDeny) + denied := s.question(siteA, otherClient, discriminator.HealthPath) + wantDisposition(t, do(t, denied), http.StatusNotFound, discriminator.DispositionDeny) again = s.question(siteA, client, "/") again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader)) wantDisposition(t, do(t, again), http.StatusBadRequest, "") // The report ends the holding, and the refused questions changed nothing. - got := s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes}) + got := s.report(discriminator.Report{RequestID: id, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) if history := s.history(); history.ResponseBytes != answerBytes { @@ -402,14 +406,14 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) { again = s.question(siteA, client, "/") again.Header.Set(requestIDHeader, id) - wantDisposition(t, do(t, again), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, again), http.StatusOK, discriminator.DispositionAllow) } func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { t.Parallel() geojsURL, asked, release := startHeldGeoJS(t) - s := startServeWAF(t, geojsURL, map[string]string{ + s := startDiscriminator(t, geojsURL, map[string]string{ lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit, }) @@ -436,7 +440,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { // A report for a question not yet answered is ignored, and that question's // id is taken. - got := s.report(servewaf.Report{RequestID: id, ResponseBytes: 1000}) + got := s.report(discriminator.Report{RequestID: id, ResponseBytes: 1000}) wantStatus(t, got, http.StatusNoContent) // This would wait on the same lookup were the id not taken. @@ -451,13 +455,14 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { select { case got := <-answered: - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) case <-time.After(waitLimit): t.Fatal("the question got no answer") } line := s.out.requestLine(t) - if line.ResponseBytes != 0 || line.ServeWAFLine != requestlog.ServeWAFLineDecision { + if line.ResponseBytes != 0 || + line.DiscriminatorLine != requestlog.DiscriminatorLineDecision { t.Errorf("the report reached the question being handled: %+v", line.Line) } @@ -466,7 +471,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { } // The true report comes after, and is the one that counts. - got = s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes}) + got = s.report(discriminator.Report{RequestID: id, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) if history := s.history(); history.ResponseBytes != answerBytes { @@ -481,16 +486,16 @@ func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) { t.Run(peer, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) req := s.question(siteA, client, "/") - req.Header.Del(servewaf.PeerHeader) + req.Header.Del(discriminator.PeerHeader) if peer != "" { - req.Header.Set(servewaf.PeerHeader, peer) + req.Header.Set(discriminator.PeerHeader, peer) } - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) if line := s.out.requestLine(t); line.PeerIP != "invalid IP" { t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP) @@ -506,13 +511,13 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) { t.Run(announced, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rulesDir: writeRules(t, "any-length header:Content-Length block .\n"), }) req := s.question(siteA, client, "/") - req.Header.Set(servewaf.ContentLengthHeader, announced) - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + req.Header.Set(discriminator.ContentLengthHeader, announced) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) if line := s.out.requestLine(t); line.ContentLength != 0 { t.Errorf("log line has content_length %d, want none", line.ContentLength) @@ -524,16 +529,16 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) { func TestHeaderSentTwiceUsesItsFirstValue(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) req := s.question(siteA, client, "/") - req.Header.Add(servewaf.ClientHeader, otherClient) - req.Header.Add(servewaf.PeerHeader, "172.18.0.3") + req.Header.Add(discriminator.ClientHeader, otherClient) + req.Header.Add(discriminator.PeerHeader, "172.18.0.3") req.Header.Set(requestIDHeader, "first") req.Header.Add(requestIDHeader, "second") - req.Header.Set(servewaf.ContentLengthHeader, "1234") - req.Header.Add(servewaf.ContentLengthHeader, "5678") - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + req.Header.Set(discriminator.ContentLengthHeader, "1234") + req.Header.Add(discriminator.ContentLengthHeader, "5678") + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) line := s.out.requestLine(t) if line.ClientIP != client || line.PeerIP != rproxyPeer || @@ -545,7 +550,7 @@ func TestHeaderSentTwiceUsesItsFirstValue(t *testing.T) { func TestSchemeAbsentIsLoggedAsHTTP(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) s.allowed(client) @@ -562,7 +567,7 @@ func sendQuestionWithPartOfItsBody(t *testing.T, conn net.Conn) { _, err := fmt.Fprintf(conn, "POST / HTTP/1.1\r\nHost: %s\r\n%s: %s\r\n%s: an-id\r\n"+ "Content-Length: 100\r\n\r\npart", - siteA, servewaf.ClientHeader, client, requestIDHeader) + siteA, discriminator.ClientHeader, client, requestIDHeader) if err != nil { t.Fatalf("send the question: %v", err) } @@ -591,8 +596,8 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startServeWAFWith(t, "", map[string]string{metricsToken: token}, - func(server *servewaf.Server) { server.ReadTimeout = readTimeout }) + s := startDiscriminatorWith(t, "", map[string]string{metricsToken: token}, + func(server *discriminator.Server) { server.ReadTimeout = readTimeout }) conn := dial(t, s.addr) sendQuestionWithPartOfItsBody(t, conn) @@ -615,7 +620,7 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) { // The id was not taken. again := s.question(siteA, client, "/") again.Header.Set(requestIDHeader, "an-id") - wantDisposition(t, do(t, again), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, again), http.StatusOK, discriminator.DispositionAllow) }) } } @@ -627,12 +632,12 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) { t.Run(strconv.Itoa(status), func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ denyNets: client, banResponse: strconv.Itoa(status), }) got := s.ask(client, "/") - wantDisposition(t, got, status, servewaf.DispositionDeny) + wantDisposition(t, got, status, discriminator.DispositionDeny) wantBody(t, got, http.StatusText(status)+"\n") wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied) }) @@ -642,34 +647,34 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) { func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ denyNets: client, banResponse: banResponseClose, metricsToken: token, }) got := s.ask(client, "/") - wantDisposition(t, got, http.StatusOK, servewaf.DispositionClose) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose) wantBody(t, got, "") // The visitor is sent nothing. wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied) - wantMetric(t, s.metrics(), `smallwebwaf_requests_total{action="denied",instance="`+ + wantMetric(t, s.metrics(), `sww_requests_total{action="denied",instance="`+ alertInstance+`",status_class="none"}`, 1) } func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rulesDir: writeRules(t, testRules), banResponse: "429", }) got := s.ask(client, "/blocked") - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRuleBlocked) got = s.ask(client, "/.env") - wantDisposition(t, got, http.StatusTooManyRequests, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny) wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests, requestlog.ActionBanned) } @@ -677,30 +682,30 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) { func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ metricsToken: token, denyNets: otherClient, }) for _, path := range []string{ - servewaf.HealthPath, servewaf.MetricsPath, servewaf.ReportPath, + discriminator.HealthPath, discriminator.MetricsPath, discriminator.ReportPath, } { got := s.ask(client, path) - wantDisposition(t, got, http.StatusNotFound, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny) } wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin) - got := s.ask(otherClient, servewaf.MetricsPath) - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + got := s.ask(otherClient, discriminator.MetricsPath) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) } func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client}) + s := startDiscriminator(t, "", map[string]string{mode: observe, denyNets: client}) got := s.ask(client, "/") - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) line := s.out.requestLine(t) wantLine(t, line, http.StatusOK, requestlog.ActionForward) @@ -710,13 +715,13 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) { func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ mode: observe, rulesDir: writeRules(t, testRules), errorBurstThreshold: "1", }) for range 2 { got := s.ask(client, "/blocked") - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) } lines := s.out.requestLines(t, 2) @@ -729,13 +734,13 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) { func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rulesDir: writeRules(t, testRules), errorBurstThreshold: "1", }) for range 2 { got := s.ask(client, "/blocked") - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) } lines := s.out.requestLines(t, 2) @@ -759,12 +764,12 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) { t.Parallel() geojsURL, _ := startGeoJS(t) - s := startServeWAF(t, geojsURL, map[string]string{ + s := startDiscriminator(t, geojsURL, map[string]string{ addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp", }) got := s.ask(fromDE, "/") - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") if asn != tc.wantASN || country != tc.wantCountry { @@ -778,7 +783,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) { func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) + s := startDiscriminator(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) // The request's own question counts no bytes: it is the report that does. id := s.allowed(client) @@ -789,7 +794,7 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) { } // 100 bytes are over the limit of 99. - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: id, Status: http.StatusBadGateway, RequestBytes: bodyBytes, ResponseBytes: answerBytes, }) @@ -806,14 +811,14 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) { } third := s.out.requestLines(t, 3)[2] - if third.ServeWAFLine != requestlog.ServeWAFLineReport || + if third.DiscriminatorLine != requestlog.DiscriminatorLineReport || third.LimitHit != minuteBytes || third.Offence != requestlog.OffenceLimit || third.BanExpires == "" { t.Errorf("the report's log line is %+v", third.Line) } got = s.ask(client, "/") - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) } func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) { @@ -821,22 +826,23 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) { geojsURL, _ := startGeoJS(t) // 100 bytes pass half of a limit of 99 and are within a limit of 199. - s := startServeWAF(t, geojsURL, map[string]string{ + s := startDiscriminator(t, geojsURL, map[string]string{ bytesLimitPerMinute: "199", countryBytesPercent: countryDEHalf, lookupTimeout: "1h", }) s.passedOn(fromKP) - wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, discriminator.DispositionAllow) s.passedOn(fromDE) - wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, + discriminator.DispositionDeny) } func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{anomalyClientPerMinute: "1000"}) + s := startDiscriminator(t, "", map[string]string{anomalyClientPerMinute: "1000"}) id := s.allowed(client) @@ -848,7 +854,7 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing t.Errorf("anomaly counters %v before the report, want none", counters) } - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: id, Status: http.StatusBadGateway, RequestBytes: bodyBytes, ResponseBytes: answerBytes, }) @@ -869,14 +875,14 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rateLimitPerMinute: "1", metricsToken: token, anomalyClientPerMinute: "1000", }) s.allowed(client) denied := s.question(siteA, client, "/") - wantDisposition(t, do(t, denied), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, do(t, denied), http.StatusForbidden, discriminator.DispositionDeny) counters := s.server.Anomalies.Snapshot() if len(counters) != 1 || counters[0].Minute.Current != 1 { @@ -888,12 +894,12 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) { t.Errorf("history is %+v, want the refused request alone", history) } - const series = `smallwebwaf_requests_total{action="rate_limited",instance="` + + const series = `sww_requests_total{action="rate_limited",instance="` + alertInstance + `",status_class="4xx"}` wantMetric(t, s.metrics(), series, 1) - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden, ResponseBytes: 1000, DurationTotal: 2, }) @@ -915,18 +921,18 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) { func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{metricsToken: token}) + s := startDiscriminator(t, "", map[string]string{metricsToken: token}) id := s.allowed(client) const series = `{action="forward",instance="` + alertInstance + `",status_class="5xx"}` metrics := s.metrics() - wantNoSeries(t, metrics, "smallwebwaf_requests_total"+series) - wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`, + wantNoSeries(t, metrics, "sww_requests_total"+series) + wantMetric(t, metrics, `sww_requests_in_flight{instance="`+alertInstance+`"}`, 1) // the scrape itself - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: id, Status: http.StatusBadGateway, RequestBytes: bodyBytes, ResponseBytes: answerBytes, DurationTotal: 5, DurationUpstreamTotal: 4, @@ -934,30 +940,32 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) { wantStatus(t, got, http.StatusNoContent) metrics = s.metrics() - wantMetric(t, metrics, "smallwebwaf_requests_total"+series, 1) - wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+series, bodyBytes) - wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+series, answerBytes) - wantMetric(t, metrics, `smallwebwaf_upstream_duration_seconds_sum{instance="`+ + wantMetric(t, metrics, "sww_requests_total"+series, 1) + wantMetric(t, metrics, "sww_request_bytes_total"+series, bodyBytes) + wantMetric(t, metrics, "sww_response_bytes_total"+series, answerBytes) + wantMetric(t, metrics, `sww_upstream_duration_seconds_sum{instance="`+ alertInstance+`"}`, 0.004) // A scrape adds its own time to the request duration when it ends, so this // check needs a server that has not been scraped yet. - unscraped := startServeWAF(t, "", map[string]string{metricsToken: token}) + unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token}) - got = unscraped.report(servewaf.Report{ + got = unscraped.report(discriminator.Report{ RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5, }) wantStatus(t, got, http.StatusNoContent) wantMetric(t, unscraped.metrics(), - `smallwebwaf_request_duration_seconds_sum{instance="`+alertInstance+`"}`, 0.005) + `sww_request_duration_seconds_sum{instance="`+alertInstance+`"}`, 0.005) } func TestReportForARequestNotHeldIsIgnored(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) + s := startDiscriminator(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) - got := s.report(servewaf.Report{RequestID: "no-such-request", ResponseBytes: 1000}) + got := s.report(discriminator.Report{ + RequestID: "no-such-request", ResponseBytes: 1000, + }) wantStatus(t, got, http.StatusNoContent) if clients := s.server.Limiter.Snapshot(); len(clients) != 0 { @@ -974,7 +982,7 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) { // A second report of the same request does not count it twice. id := s.allowed(client) for range 2 { - got = s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes}) + got = s.report(discriminator.Report{RequestID: id, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) } @@ -1023,7 +1031,7 @@ func encodedReport( func TestReportThatIsNotOneObjectWithAllItsFieldsIsRefused(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) id := s.allowed(client) whole := encodedReport(t, id, nil) @@ -1059,7 +1067,7 @@ func TestReportThatIsNotOneObjectWithAllItsFieldsIsRefused(t *testing.T) { func TestReportOver1MiBIsRefusedAndOneOfExactly1MiBIsTaken(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) id := s.allowed(client) // The padding is a field of no meaning here, so only the size can refuse it. @@ -1085,7 +1093,7 @@ func TestReportOver1MiBIsRefusedAndOneOfExactly1MiBIsTaken(t *testing.T) { func TestReportWithAValueOutOfRangeIsRefused(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) id := s.allowed(client) cases := []struct { @@ -1127,7 +1135,7 @@ func TestReportWithAValueOutOfRangeIsRefused(t *testing.T) { func TestReportValuesAtTheEndsOfTheirRangesAreTaken(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off, }) @@ -1160,9 +1168,9 @@ func TestReportValuesAtTheEndsOfTheirRangesAreTaken(t *testing.T) { func TestBytesAtTheirMostEachWayBanRatherThanOverflow(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) + s := startDiscriminator(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: s.allowed(client), Status: http.StatusOK, RequestBytes: reportedBytesLimit, ResponseBytes: reportedBytesLimit, }) @@ -1178,7 +1186,7 @@ func TestBytesAtTheirMostEachWayBanRatherThanOverflow(t *testing.T) { func wantReportsFields(t *testing.T, line logLine) { t.Helper() - if line.ServeWAFLine != requestlog.ServeWAFLineReport || + if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || line.Status != http.StatusCreated || line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes || line.DurationTotal != 12.5 || line.DurationUpstreamTotal == nil || @@ -1193,7 +1201,7 @@ func wantSameDecision(t *testing.T, reported, decided logLine) { t.Helper() same := reported.Line - same.ServeWAFLine = decided.ServeWAFLine + same.DiscriminatorLine = decided.DiscriminatorLine same.Status = decided.Status same.RequestBytes = decided.RequestBytes same.ResponseBytes = decided.ResponseBytes @@ -1209,12 +1217,12 @@ func wantSameDecision(t *testing.T, reported, decided logLine) { } } -// wantDecisionsFields checks what servewaf answered and noted on the decision's +// wantDecisionsFields checks what discriminator answered and noted on the decision's // line, of a request with these fields. func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) { t.Helper() - if line.ServeWAFLine != requestlog.ServeWAFLineDecision || + if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision || line.Status != http.StatusOK || line.ResponseBytes != 0 || line.DurationUpstreamTotal != nil || line.DurationChecks == nil || line.RequestID != id || line.Query != query || @@ -1228,7 +1236,7 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) { func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ logRequestHeaders: "accept", rulesDir: writeRules(t, "seen path log ^/page$\n"), }) @@ -1247,9 +1255,9 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T req.Header.Set("X-Forwarded-Proto", "https") id := req.Header.Get(requestIDHeader) - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: id, Status: http.StatusCreated, RequestBytes: bodyBytes, ResponseBytes: answerBytes, DurationTotal: 12.5, DurationUpstreamTotal: 10.25, @@ -1296,18 +1304,19 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) { "close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/", 0, requestlog.ActionDenied, }, - {"own prefix", nil, servewaf.HealthPath, http.StatusNotFound, requestlog.ActionAdmin}, + {"own prefix", nil, discriminator.HealthPath, http.StatusNotFound, + requestlog.ActionAdmin}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", tc.env) + s := startDiscriminator(t, "", tc.env) req := s.question(siteA, client, tc.path) id := req.Header.Get(requestIDHeader) do(t, req) - got := s.report(servewaf.Report{ + got := s.report(discriminator.Report{ RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3, }) wantStatus(t, got, http.StatusNoContent) @@ -1315,7 +1324,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) { line := s.out.requestLines(t, 2)[1] wantLine(t, line, tc.status, tc.action) - if line.ServeWAFLine != requestlog.ServeWAFLineReport || + if line.DiscriminatorLine != requestlog.DiscriminatorLineReport || line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotal != 3 || line.DurationUpstreamTotal != nil { t.Errorf("the report's line is %+v", line.Line) @@ -1327,7 +1336,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) { func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rateLimitExemptNets: otherClient, }) @@ -1343,18 +1352,18 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) { t.Fatalf("question: %v", err) } - wantDisposition(t, readAnswer(res), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, readAnswer(res), http.StatusOK, discriminator.DispositionAllow) } // The table is full again with next and those, without oldest. - got := s.report(servewaf.Report{RequestID: oldest, ResponseBytes: answerBytes}) + got := s.report(discriminator.Report{RequestID: oldest, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) if counted := s.history().Requests; counted != 0 { t.Errorf("history holds %d requests, want the dropped one not counted", counted) } - got = s.report(servewaf.Report{RequestID: next, ResponseBytes: answerBytes}) + got = s.report(discriminator.Report{RequestID: next, ResponseBytes: answerBytes}) wantStatus(t, got, http.StatusNoContent) if counted := s.history().Requests; counted != 1 { @@ -1365,13 +1374,13 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) { func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)}) + s := startDiscriminator(t, "", map[string]string{rulesDir: writeRules(t, testRules)}) got := do(t, s.question(siteA, client, "/.env")) - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) got = do(t, s.question(siteB, client, "/")) - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) lines := s.out.requestLines(t, 2) if lines[0].Host != siteA || lines[1].Host != siteB { @@ -1392,19 +1401,19 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) { func TestRequestsOnEverySiteCountTowardOneSetOfLimits(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"}) + s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"}) s.allowed(client) got := do(t, s.question(siteB, client, "/")) - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) } func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { t.Parallel() // Were a check run on its requests, localhost's would be refused. - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ metricsToken: token, adminToken: adminSecret, denyNets: localhost, }) @@ -1412,14 +1421,14 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { path, authorization string want int }{ - {servewaf.HealthPath, "", http.StatusOK}, - {servewaf.MetricsPath, bearer, http.StatusOK}, - {servewaf.MetricsPath, "", http.StatusUnauthorized}, - {servewaf.BansPath, adminBearer, http.StatusOK}, - {servewaf.BansPath, "", http.StatusUnauthorized}, - {servewaf.ClientsPath + client, adminBearer, http.StatusOK}, - {servewaf.ReportPath, "", http.StatusNotFound}, - {"/_smallwebwaf/other", "", http.StatusNotFound}, + {discriminator.HealthPath, "", http.StatusOK}, + {discriminator.MetricsPath, bearer, http.StatusOK}, + {discriminator.MetricsPath, "", http.StatusUnauthorized}, + {discriminator.BansPath, adminBearer, http.StatusOK}, + {discriminator.BansPath, "", http.StatusUnauthorized}, + {discriminator.ClientsPath + client, adminBearer, http.StatusOK}, + {discriminator.ReportPath, "", http.StatusNotFound}, + {"/_sww/other", "", http.StatusNotFound}, {"/", "", http.StatusNotFound}, } { req := newRequest(t, http.MethodGet, s.addr, tc.path, http.NoBody) @@ -1430,10 +1439,10 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { req = s.question(siteA, client, tc.path) req.Header.Set("Authorization", tc.authorization) - if strings.HasPrefix(tc.path, "/_smallwebwaf/") { - wantDisposition(t, do(t, req), http.StatusNotFound, servewaf.DispositionDeny) + if strings.HasPrefix(tc.path, "/_sww/") { + wantDisposition(t, do(t, req), http.StatusNotFound, discriminator.DispositionDeny) } else { - wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow) } } } @@ -1441,13 +1450,13 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) - wantStatus(t, get(t, s.addr, servewaf.HealthPath), http.StatusOK) + wantStatus(t, get(t, s.addr, discriminator.HealthPath), http.StatusOK) line := s.out.requestLine(t) - if _, there := line.fields["servewaf_line"]; there || - line.Path != servewaf.HealthPath { + if _, there := line.fields["discriminator_line"]; there || + line.Path != discriminator.HealthPath { t.Errorf("the health check's log line is %+v", line.fields) } } @@ -1455,16 +1464,16 @@ func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) { func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) req := s.questionWith(http.MethodPost, siteA, client, "/", strings.NewReader("a body")) - req.Header.Set(servewaf.ContentLengthHeader, "1073741824") + req.Header.Set(discriminator.ContentLengthHeader, "1073741824") req.Header.Set(forwardedFor, otherClient) req.Header.Set("X-Long", strings.Repeat("x", 40<<10)) got := do(t, req) - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) line := s.out.requestLine(t) if line.ClientIP != client || line.ContentLength != 1<<30 { @@ -1476,7 +1485,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) { func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{"SWWAF_LOG_LEVEL": "error"}) + s := startDiscriminator(t, "", map[string]string{"SWW_LOG_LEVEL": "error"}) // A refused question would write a process warning, held back at error. req := s.question(siteA, client, "/") @@ -1496,7 +1505,7 @@ func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { func TestServerHasTheTimeLimitsSPECGives(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", nil) + s := startDiscriminator(t, "", nil) if s.server.ReadTimeout != time.Minute || s.server.WriteTimeout != time.Minute || s.server.IdleTimeout != 2*time.Minute { @@ -1508,11 +1517,11 @@ func TestServerHasTheTimeLimitsSPECGives(t *testing.T) { func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ rulesDir: writeRules(t, ` -own-client header:X-Smallwebwaf-Client block . -own-peer header:X-Smallwebwaf-Peer block . -own-length header:X-Smallwebwaf-Content-Length block . +own-client header:X-Sww-Client block . +own-peer header:X-Sww-Peer block . +own-length header:X-Sww-Content-Length block . announced header:Content-Length block ^70000$ carried header:Content-Length block ^6$ `), @@ -1532,7 +1541,7 @@ carried header:Content-Length block ^6$ strings.NewReader("a body")) if tc.announced != "" { - req.Header.Set(servewaf.ContentLengthHeader, tc.announced) + req.Header.Set(discriminator.ContentLengthHeader, tc.announced) } wantStatus(t, do(t, req), tc.want) @@ -1542,7 +1551,7 @@ carried header:Content-Length block ^6$ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{ + s := startDiscriminator(t, "", map[string]string{ wafMode: block, wafBodyLimit: "64K", }) @@ -1550,28 +1559,28 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) { req := s.questionWith(http.MethodPost, siteA, client, "/", strings.NewReader(body)) req.Header.Set("Content-Type", contentType) - req.Header.Set(servewaf.ContentLengthHeader, "100000") + req.Header.Set(discriminator.ContentLengthHeader, "100000") return do(t, req) } got := ask(formData, "id=1'%20OR%20'1'='1") - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) // The first 64 KiB of a longer JSON body cannot be parsed, which is no // attack, so the Core Rule Set does not read it. - cut := `{"text":"` + strings.Repeat("x", servewaf.QuestionBodyMaxBytes) - got = ask("application/json", cut[:servewaf.QuestionBodyMaxBytes]) - wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) + cut := `{"text":"` + strings.Repeat("x", discriminator.QuestionBodyMaxBytes) + got = ask("application/json", cut[:discriminator.QuestionBodyMaxBytes]) + wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow) } func TestCoreRuleSetAlertNamesTheSite(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{wafMode: block}) + s := startDiscriminator(t, "", map[string]string{wafMode: block}) got := s.ask(client, sqlInjection) - wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny) waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock || @@ -1583,10 +1592,11 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) { func TestReputationHitAlertNamesTheSite(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{blocklistURLs: dropURL}) + s := startDiscriminator(t, "", map[string]string{blocklistURLs: dropURL}) loadLists(t, s.server, map[string][]string{dropURL: {client}}) - wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, servewaf.DispositionDeny) + wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, + discriminator.DispositionDeny) waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || @@ -1598,7 +1608,7 @@ func TestReputationHitAlertNamesTheSite(t *testing.T) { func TestAnomalyAlertNamesTheSite(t *testing.T) { t.Parallel() - s := startServeWAF(t, "", map[string]string{anomalyClientPerMinute: "1"}) + s := startDiscriminator(t, "", map[string]string{anomalyClientPerMinute: "1"}) for range 2 { s.passedOn(client) diff --git a/internal/servewaf/errorburst_test.go b/internal/discriminator/errorburst_test.go similarity index 92% rename from internal/servewaf/errorburst_test.go rename to internal/discriminator/errorburst_test.go index ef924c2..9396f51 100644 --- a/internal/servewaf/errorburst_test.go +++ b/internal/discriminator/errorburst_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "maps" @@ -10,14 +10,14 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) -const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" +const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" -// refused is a request the tests here send, which servewaf refuses after a rule +// refused is a request the tests here send, which discriminator refuses after a rule // file match. type refused int @@ -48,10 +48,10 @@ func (r refused) send(s *sender, from string) logLine { } // startForErrorBurst is startWithClock with testRules, the metrics token and -// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. +// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env. func startForErrorBurst( t *testing.T, threshold string, env map[string]string, -) (*sender, *clock, *servewaf.Server) { +) (*sender, *clock, *discriminator.Server) { t.Helper() settings := map[string]string{ @@ -156,12 +156,12 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { } metrics := s.scrape() - wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ + wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="refusals",window="minute"}`, 1) - wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1) + wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, - `smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3) - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) + `sww_offences_total{instance="app",kind="rule_blocked"}`, 3) + wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) } func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) { @@ -258,15 +258,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS - exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.60" // in SWW_ALLOW_NETS + exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS ) s, _, _ := startForErrorBurst(t, "1", map[string]string{ allowNets: allowed, rateLimitExemptNets: exempt, }) - // A client in SWWAF_ALLOW_NETS is not refused, and so not counted. + // A client in SWW_ALLOW_NETS is not refused, and so not counted. for range 3 { s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) } diff --git a/internal/servewaf/health_test.go b/internal/discriminator/health_test.go similarity index 85% rename from internal/servewaf/health_test.go rename to internal/discriminator/health_test.go index 8d412ca..ae32ac6 100644 --- a/internal/servewaf/health_test.go +++ b/internal/discriminator/health_test.go @@ -1,11 +1,11 @@ -package servewaf_test +package discriminator_test import ( "net/http" "testing" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { @@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { ) for range healthChecks { - got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK) + got := s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK) if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ diff --git a/internal/servewaf/history_test.go b/internal/discriminator/history_test.go similarity index 88% rename from internal/servewaf/history_test.go rename to internal/discriminator/history_test.go index bb82226..710e326 100644 --- a/internal/servewaf/history_test.go +++ b/internal/discriminator/history_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "io" @@ -8,9 +8,9 @@ import ( "testing" "time" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { @@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { Requests: 4, Forwarded: 2, Refused: 2, - // The app answers with no body, smallwebwaf with its status text. + // The app answers with no body, sww with its status text. ResponseBytes: 2 * int64(len("Forbidden\n")), Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2}, Offences: ratelimit.Offences{Limit: 1}, @@ -100,8 +100,9 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) { s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) - s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK) - s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized) + s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK) + s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "", + http.StatusUnauthorized) s.scrape() if clients := server.Limiter.Snapshot(); len(clients) != 0 { @@ -115,7 +116,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) { app := startApp(t, func(http.ResponseWriter, *http.Request) {}) addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil) - wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) + wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound) out.endedLine(t) history := historyOf(t, server, localhost) @@ -126,7 +127,9 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) { } // historyOf returns the history of the client at addr. -func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History { +func historyOf( + t *testing.T, server *discriminator.Server, addr string, +) ratelimit.History { t.Helper() client := netip.MustParsePrefix(addr + "/32") diff --git a/internal/servewaf/lookup.go b/internal/discriminator/lookup.go similarity index 93% rename from internal/servewaf/lookup.go rename to internal/discriminator/lookup.go index 6dd22e9..b6cf86b 100644 --- a/internal/servewaf/lookup.go +++ b/internal/discriminator/lookup.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "context" @@ -10,7 +10,7 @@ import ( ) // The headers in which an answer of allow gives rproxy the client's AS number -// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes +// and country, for the app, while SWW_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 ( @@ -20,7 +20,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, unless -// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or +// SWW_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/servewaf/lookup_test.go b/internal/discriminator/lookup_test.go similarity index 97% rename from internal/servewaf/lookup_test.go rename to internal/discriminator/lookup_test.go index 189d25d..a5e70ba 100644 --- a/internal/servewaf/lookup_test.go +++ b/internal/discriminator/lookup_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -20,7 +20,7 @@ import ( // and country. type asnAndCountry struct{ asn, asName, country string } -// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the +// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the // lookup database. const fileSource = "file" @@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing. addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now, map[string]string{ trustedProxies: trustLocalhost, - alertWebhookURL: "https://alerts.example/smallwebwaf", + alertWebhookURL: "https://alerts.example/sww", rateLimitPerMinute: "1", }) s := &sender{t: t, addr: addr, out: out} @@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) { // clock of the test's own: the wait lasts exactly as long as it should, // however slowly the test process runs. Nothing in it may wait on the // network, which would keep that clock from moving on: the request is - // handed to servewaf's handler, and GeoJS is one that never answers. + // handed to discriminator's handler, and GeoJS is one that never answers. synctest.Test(t, func(t *testing.T) { // Not the default. The exclusive list needs the answer, and the app // is never reached. const timeout = 3 * time.Second - server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, + server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, map[string]string{ lookupTimeout: timeout.String(), allowedCountries: "DE", diff --git a/internal/servewaf/metrics_test.go b/internal/discriminator/metrics_test.go similarity index 64% rename from internal/servewaf/metrics_test.go rename to internal/discriminator/metrics_test.go index 7c903c1..19a09b9 100644 --- a/internal/servewaf/metrics_test.go +++ b/internal/discriminator/metrics_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "io" @@ -11,15 +11,15 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( - metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name - metricsTopN = "SWWAF_METRICS_TOP_N" - // token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a + 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 // request carries it. token = "0123456789abcdef0123456789abcdef" bearer = "Bearer " + token @@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) { // An empty token does not match the unset one either. for _, authorization := range []string{bearer, "Bearer ", ""} { - s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "", + s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "", http.StatusNotFound) } } @@ -46,18 +46,19 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) { method, path, authorization string status int }{ - {http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized}, + {http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized}, { - http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token), + http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token), http.StatusUnauthorized, }, - {http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized}, - {http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK}, - {http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK}, - {http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound}, - {http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound}, - {http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound}, - {http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound}, + {http.MethodGet, discriminator.MetricsPath, "Basic " + token, + http.StatusUnauthorized}, + {http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK}, + {http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK}, + {http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound}, + {http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound}, + {http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound}, + {http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound}, } { got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status) @@ -68,7 +69,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) { } if tc.status == http.StatusOK && - !strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") { + !strings.Contains(string(got.body), "# TYPE sww_requests_total counter") { t.Errorf("the metrics are\n%s", got.body) } } @@ -86,7 +87,7 @@ func TestMetricsCountTheTraffic(t *testing.T) { got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) wantStatus(t, got, http.StatusOK) - wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) + wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound) out.endedLines(t, 2) forward := `{action="forward",instance="app",status_class="2xx"}` @@ -94,17 +95,17 @@ func TestMetricsCountTheTraffic(t *testing.T) { // The request for the metrics is itself under way. metrics := scrape(t, server.Addr) - wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1) - wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1) - wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3) - wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5) - wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound, + wantMetric(t, metrics, "sww_requests_total"+forward, 1) + wantMetric(t, metrics, "sww_requests_total"+notFound, 1) + wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3) + wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5) + wantMetric(t, metrics, "sww_response_bytes_total"+notFound, float64(len("Not Found\n"))) wantMetric(t, metrics, - `smallwebwaf_request_duration_seconds_count{instance="app"}`, 2) + `sww_request_duration_seconds_count{instance="app"}`, 2) wantMetric(t, metrics, - `smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1) + `sww_upstream_duration_seconds_count{instance="app"}`, 1) + wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1) metric(t, metrics, `go_goroutines{instance="app"}`) metric(t, metrics, `process_start_time_seconds{instance="app"}`) } @@ -112,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWWAF_DENY_NETS + const denied = "192.0.2.50" // in SWW_DENY_NETS s, clk, _ := startWithClock(t, "", map[string]string{ metricsToken: token, @@ -123,7 +124,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { maxBanDuration: "2h", }) - // SWWAF_BAN_RESPONSE=close sends no status at all. + // SWW_BAN_RESPONSE=close sends no status at all. s.get(denied, 0, requestlog.ActionDenied) // A first broken limit bans for an hour. @@ -132,31 +133,31 @@ func TestMetricsCountLimitsAndBans(t *testing.T) { metrics := s.scrape() wantMetric(t, metrics, - `smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1) - wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ + `sww_requests_total{action="denied",instance="app",status_class="none"}`, 1) + wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="requests",window="minute"}`, 1) - wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1) - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0) + wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1) + wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1) + wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1) + wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0) clk.advance(time.Hour) - wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0) + wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0) // A limit broken again right after would ban for three hours, longer - // than SWWAF_MAX_BAN_DURATION, so the ban is permanent. + // than SWW_MAX_BAN_DURATION, so the ban is permanent. s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, 0, requestlog.ActionRateLimited) metrics = s.scrape() - wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ + wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+ `kind="requests",window="minute"}`, 2) - wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2) - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2) - wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) + wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2) + wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2) + wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1) + wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1) // denied and client. - wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2) + wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2) } func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { @@ -167,7 +168,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { adminToken: adminSecret, }) - const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}` + const admins = `sww_bans_made_total{cause="admin",instance="app"}` wantMetric(t, s.scrape(), admins, 0) @@ -180,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { wantMetric(t, s.scrape(), admins, 1) // And a ban made through the endpoint. - s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK) + s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK) wantMetric(t, s.scrape(), admins, 2) } @@ -233,25 +234,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { lines++ wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) + `sww_country_requests_total{country="KP",instance="app"}`, 3) wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2) + `sww_country_requests_total{country="DE",instance="app"}`, 2) wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="other",instance="app"}`, 1) + `sww_country_requests_total{country="other",instance="app"}`, 1) wantMetric(t, metrics, - `smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3) + `sww_country_list_refusals_total{country="KP",instance="app"}`, 3) wantMetric(t, metrics, - `smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0) + `sww_country_request_bytes_total{country="KP",instance="app"}`, 0) wantMetric(t, metrics, - `smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6) + `sww_country_request_bytes_total{country="DE",instance="app"}`, 6) wantMetric(t, metrics, - `smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`, + `sww_country_response_bytes_total{country="KP",instance="app"}`, float64(3*len("Forbidden\n"))) wantMetric(t, metrics, - `smallwebwaf_country_response_bytes_total{country="other",instance="app"}`, + `sww_country_response_bytes_total{country="other",instance="app"}`, float64(len("hello"))) wantNoSeries(t, metrics, - `smallwebwaf_country_requests_total{country="FR",instance="app"}`) + `sww_country_requests_total{country="FR",instance="app"}`) // Once FR is busier than DE, it takes DE's place: its series counts // from then on, and DE's is gone. @@ -259,15 +260,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { metrics = scrape(t, server.Addr) wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) + `sww_country_requests_total{country="KP",instance="app"}`, 3) wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2) + `sww_country_requests_total{country="FR",instance="app"}`, 2) wantMetric(t, metrics, - `smallwebwaf_country_requests_total{country="other",instance="app"}`, 2) + `sww_country_requests_total{country="other",instance="app"}`, 2) wantNoSeries(t, metrics, - `smallwebwaf_country_requests_total{country="DE",instance="app"}`) + `sww_country_requests_total{country="DE",instance="app"}`) wantNoSeries(t, metrics, - `smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`) + `sww_country_request_bytes_total{country="DE",instance="app"}`) } func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { @@ -298,15 +299,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { metrics := s.scrape() wantMetric(t, metrics, - `smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2) + `sww_asn_requests_total{asn="AS64501",instance="app"}`, 2) wantMetric(t, metrics, - `smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1) + `sww_asn_requests_total{asn="other",instance="app"}`, 1) wantMetric(t, metrics, - `smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0) + `sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0) wantMetric(t, metrics, - `smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0) + `sww_asn_response_bytes_total{asn="other",instance="app"}`, 0) wantNoSeries(t, metrics, - `smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`) + `sww_asn_requests_total{asn="AS64502",instance="app"}`) } func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { @@ -327,26 +328,26 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { }) // GeoJS fails, so the client counts as coming from an unknown country, - // which SWWAF_DENIED_COUNTRIES does not refuse. + // which SWW_DENIED_COUNTRIES does not refuse. 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 SWWAF_LOOKUP_TIMEOUT is up, so + // The client stops waiting for GeoJS once SWW_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) - for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 && + for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 && time.Now().Before(deadline) { time.Sleep(pollInterval) metrics = scrape(t, server.Addr) } - wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1) + wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1) + wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1) + wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1) } // keptAnswer returns GeoJS's answer that the client at addr is in @@ -360,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer { } } -// scrape asks servewaf at addr for the metrics, with the token, and returns +// scrape asks discriminator at addr for the metrics, with the token, and returns // them. func scrape(t *testing.T, addr string) string { t.Helper() - req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody) + req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody) req.Header.Set("Authorization", bearer) got := do(t, req) @@ -380,14 +381,14 @@ func scrape(t *testing.T, addr string) string { func (s *sender) scrape() string { s.t.Helper() - return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "", + return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "", http.StatusOK).body) } // metric returns the value of series in metrics, which are in the // Prometheus text format. series is a name and its labels in the order of // their names, such as -// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the +// sww_offences_total{instance="app",kind="limit"}. It fails the // test if there is no such series. func metric(t *testing.T, metrics, series string) float64 { t.Helper() diff --git a/internal/servewaf/observe_test.go b/internal/discriminator/observe_test.go similarity index 95% rename from internal/servewaf/observe_test.go rename to internal/discriminator/observe_test.go index ac95327..b81e9b9 100644 --- a/internal/servewaf/observe_test.go +++ b/internal/discriminator/observe_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -11,19 +11,19 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// observe is the value of SWWAF_MODE for observe mode. +// observe is the value of SWW_MODE for observe mode. const observe = "observe" func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { t.Parallel() const ( - denied = "192.0.2.50" // in SWWAF_DENY_NETS + denied = "192.0.2.50" // in SWW_DENY_NETS banned = otherClient // under a ban read from bans.json ) for _, tc := range []struct { - setting string // "" leaves SWWAF_MODE at its default + setting string // "" leaves SWW_MODE at its default observe bool }{ {"", false}, diff --git a/internal/servewaf/proxy.go b/internal/discriminator/proxy.go similarity index 81% rename from internal/servewaf/proxy.go rename to internal/discriminator/proxy.go index 48e884b..b4da7db 100644 --- a/internal/servewaf/proxy.go +++ b/internal/discriminator/proxy.go @@ -1,7 +1,7 @@ -// Package servewaf is the server the servewaf command runs. It answers rproxy's -// questions, takes rproxy's reports and answers its own endpoints, and writes a -// log line for each. -package servewaf +// Package discriminator is the server the discriminator command runs. It answers +// rproxy's questions, takes rproxy's reports and answers its own endpoints, and +// writes a log line for each. +package discriminator import ( "fmt" @@ -24,45 +24,46 @@ import ( "sneak.berlin/go/smallwebwaf/internal/waf" ) -// adminPrefix starts the path of each of servewaf's own endpoints. The app never +// adminPrefix starts the path of each of discriminator's own endpoints. The app never // sees a path under it. -const adminPrefix = "/_smallwebwaf/" +const adminPrefix = "/_sww/" -// HealthPath is the health endpoint of servewaf and of rproxy, which the +// HealthPath is the health endpoint of discriminator and of rproxy, which the // container's health check asks. -const HealthPath = "/_smallwebwaf/healthz" +const HealthPath = "/_sww/healthz" // MetricsPath is where the metrics are, for a request that carries -// SWWAF_METRICS_TOKEN. -const MetricsPath = "/_smallwebwaf/metrics" +// SWW_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 SWWAF_ADMIN_TOKEN. -const BansPath = "/_smallwebwaf/bans" +// a client's address, lifts them, with SWW_ADMIN_TOKEN. +const BansPath = "/_sww/bans" -// ClientsPath is where an admin asks what servewaf knows of a client, by the -// client's address after it, with SWWAF_ADMIN_TOKEN. -const ClientsPath = "/_smallwebwaf/clients/" +// ClientsPath is where an admin asks what discriminator knows of a client, by +// the client's address after it, with SWW_ADMIN_TOKEN. +const ClientsPath = "/_sww/clients/" // Params are what New needs. type Params struct { Config *config.Config // RequestLog receives two JSON lines per question, one at its decision and - // one when its report comes, and one per request to servewaf's own endpoints. + // one when its report comes, and one per request to discriminator's own + // endpoints. RequestLog io.Writer // ProcessLog receives the process's own messages. ProcessLog *slog.Logger // GeoJSURL is where clients' AS numbers and countries are looked up - // while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. + // while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL. GeoJSURL string // AbuseIPDBURL is where clients are checked with AbuseIPDB while - // SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. + // SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. AbuseIPDBURL string // LookupFile is the lookup database they are looked up in while - // SWWAF_LOOKUP_SOURCE is file, and nil otherwise. + // SWW_LOOKUP_SOURCE is file, and nil otherwise. LookupFile *lookup.File // CoreRuleSet is what NewCoreRuleSet returns: what inspects requests - // while SWWAF_WAF_MODE is not off, and nil while it is. + // while SWW_WAF_MODE is not off, and nil while it is. CoreRuleSet *waf.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, @@ -71,18 +72,18 @@ type Params struct { // Rules are the rule files' rules, which each request is checked // against. Rules *rules.Files - // Alerts receive the alert for each ban servewaf makes or makes + // Alerts receive the alert for each ban discriminator makes or makes // 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 - // SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list + // SWW_WAF_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 servewaf runs, with the parts whose state the state -// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the +// 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 // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // AbuseIPDB's scores and checks spent, and the metrics. type Server struct { @@ -174,7 +175,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 SWWAF_ABUSEIPDB_KEY is set. +// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set. func newReputation( params Params, m *metrics.Metrics, ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { @@ -206,10 +207,10 @@ func newReputation( return lists, dnsbl, abuseIPDB } -// NewCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, -// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies -// up to SWWAF_WAF_BODY_LIMIT, with the exclusions in -// SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWWAF_WAF_MODE is off. It +// 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 // 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 waf.New refuses is @@ -226,7 +227,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) { ExclusionsFile: cfg.CoreRuleSetExclusionsFile, }) if err != nil { - return nil, fmt.Errorf("SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) + return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) } if cfg.WAFMode == config.WAFModeOff { @@ -236,7 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) { return coreRuleSet, nil } -// handler is servewaf's handler. It holds what every request shares; what +// handler is discriminator's handler. It holds what every request shares; what // belongs to one request is in a request. type handler struct { config *config.Config diff --git a/internal/servewaf/proxy_test.go b/internal/discriminator/proxy_test.go similarity index 79% rename from internal/servewaf/proxy_test.go rename to internal/discriminator/proxy_test.go index 0d55d92..02fdd56 100644 --- a/internal/servewaf/proxy_test.go +++ b/internal/discriminator/proxy_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "bufio" @@ -17,11 +17,11 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rules" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( @@ -55,33 +55,33 @@ const ( // The settings the tests set. const ( - mode = "SWWAF_MODE" + mode = "SWW_MODE" trustedProxies = "SWRP_TRUSTED_PROXIES" - ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" - maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" - allowNets = "SWWAF_ALLOW_NETS" - rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" - denyNets = "SWWAF_DENY_NETS" - rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" - rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" - rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" - lookupSource = "SWWAF_LOOKUP_SOURCE" - lookupDBPath = "SWWAF_LOOKUP_DB_PATH" - lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" - addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" - deniedCountries = "SWWAF_DENIED_COUNTRIES" - allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" - banResponse = "SWWAF_BAN_RESPONSE" - limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" - limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" - maxBanDuration = "SWWAF_MAX_BAN_DURATION" - maxBans = "SWWAF_MAX_BANS" - banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" - instanceName = "SWWAF_INSTANCE_NAME" - logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" - attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" - rulesDir = "SWWAF_RULES_DIR" - wafMode = "SWWAF_WAF_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" + 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" + deniedCountries = "SWW_DENIED_COUNTRIES" + 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" ) // off is the value that switches a setting off. @@ -90,13 +90,13 @@ const off = "off" // on is the value that switches a setting that takes true or false on. const on = "true" -// output collects what smallwebwaf writes on stdout. +// output collects what sww writes on stdout. type output struct { mu sync.Mutex buf bytes.Buffer } -// Write adds lines smallwebwaf writes. +// Write adds lines sww writes. func (o *output) Write(p []byte) (int, error) { o.mu.Lock() defer o.mu.Unlock() @@ -175,9 +175,9 @@ func (o *output) requestLine(t *testing.T) logLine { return o.requestLines(t, 1)[0] } -// endedLines waits for count requests that went through rproxy, or to servewaf's +// endedLines waits for count requests that went through rproxy, or to discriminator's // own endpoints, to have ended, and returns the last log line of each, in the -// order servewaf took them. A question's last line is the one its report writes. +// order discriminator took them. A question's last line is the one its report writes. func (o *output) endedLines(t *testing.T, count int) []logLine { t.Helper() @@ -187,7 +187,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine { ended := len(found) >= count for _, line := range found[:min(count, len(found))] { - if line.ServeWAFLine == requestlog.ServeWAFLineDecision { + if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision { ended = false } } @@ -205,7 +205,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine { } // lastLines returns the last request log line written so far of each request, -// in the order servewaf took them. +// in the order discriminator took them. func (o *output) lastLines(t *testing.T) []logLine { t.Helper() @@ -271,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server { return server } -// startProxy starts servewaf, and rproxy in front of the app at appURL, each with +// startProxy starts discriminator, and rproxy in front of the app at appURL, each with // the settings in env on top of the defaults, and returns where rproxy listens -// and what servewaf writes. +// and what discriminator writes. func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) { t.Helper() @@ -293,14 +293,14 @@ func startProxyWithGeoJS( } // startProxyWithClock is startProxyWithGeoJS with requests counted and -// bans made by the time now tells, and returns servewaf's server as well, whose -// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty -// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app, +// 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, // the label instance of every metric. func startProxyWithClock( t *testing.T, appURL, geojsURL string, now func() time.Time, env map[string]string, -) (string, *output, *servewaf.Server) { +) (string, *output, *discriminator.Server) { t.Helper() addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env) @@ -309,14 +309,14 @@ func startProxyWithClock( } // startProxyWithAlerts is startProxyWithClock, and returns the queue of -// the alerts servewaf raises as well, as newServeWAF makes them. +// the alerts discriminator raises as well, as newDiscriminator makes them. func startProxyWithAlerts( t *testing.T, appURL, geojsURL string, now func() time.Time, env map[string]string, -) (string, *output, *servewaf.Server, *alerts.Queue) { +) (string, *output, *discriminator.Server, *alerts.Queue) { t.Helper() - server, out, alertQueue := newServeWAF(t, geojsURL, now, env) + server, out, alertQueue := newDiscriminator(t, geojsURL, now, env) serve(t, server) return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue @@ -324,7 +324,7 @@ func startProxyWithAlerts( // serve serves server on a port of its own until the test ends, and sets its // Addr to where it listens. -func serve(t *testing.T, server *servewaf.Server) { +func serve(t *testing.T, server *discriminator.Server) { t.Helper() listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") @@ -343,17 +343,17 @@ func serve(t *testing.T, server *servewaf.Server) { }) } -// startRProxy starts rproxy in front of the app at appURL, asking the servewaf -// at serveWAFAddr, with the settings in env on top of the defaults, and returns +// 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. func startRProxy( - t *testing.T, serveWAFAddr, appURL string, env map[string]string, + t *testing.T, discriminatorAddr, appURL string, env map[string]string, ) string { t.Helper() settings := map[string]string{ - "SWRP_WAF_BACKEND": "http://" + serveWAFAddr, + "SWRP_WAF_BACKEND": "http://" + discriminatorAddr, "SWRP_UPSTREAM_URL": appURL, "SWRP_TIMEOUT_MS": "60000", } @@ -390,28 +390,29 @@ func startRProxy( return listener.Addr().String() } -// newServeWAF makes servewaf's server, without starting it, and returns it, what +// newDiscriminator makes discriminator's server, without starting it, and +// returns it, what // it writes, and the queue of the alerts it raises, as the 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 -// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup -// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB -// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless +// 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. -func newServeWAF( +func newDiscriminator( t *testing.T, geojsURL string, now func() time.Time, env map[string]string, -) (*servewaf.Server, *output, *alerts.Queue) { +) (*discriminator.Server, *output, *alerts.Queue) { t.Helper() - return newServeWAFWith(t, servewaf.New, geojsURL, now, env) + return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env) } -// newServeWAFWith is newServeWAF for the server build makes. -func newServeWAFWith( - t *testing.T, build func(servewaf.Params) *servewaf.Server, +// newDiscriminatorWith is newDiscriminator for the server build makes. +func newDiscriminatorWith( + t *testing.T, build func(discriminator.Params) *discriminator.Server, geojsURL string, now func() time.Time, env map[string]string, -) (*servewaf.Server, *output, *alerts.Queue) { +) (*discriminator.Server, *output, *alerts.Queue) { t.Helper() settings := map[string]string{ @@ -463,12 +464,12 @@ func newServeWAFWith( } } - coreRuleSet, err := servewaf.NewCoreRuleSet(cfg) + coreRuleSet, err := discriminator.NewCoreRuleSet(cfg) if err != nil { t.Fatalf("Core Rule Set: %v", err) } - server := build(servewaf.Params{ + server := build(discriminator.Params{ Config: cfg, RequestLog: out, ProcessLog: processLog, @@ -513,7 +514,7 @@ func readAnswer(res *http.Response) answer { return answer{status: res.StatusCode, header: res.Header, body: body, err: err} } -// newRequest makes a request for path to addr, where rproxy or servewaf listens. +// newRequest makes a request for path to addr, where rproxy or discriminator listens. func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request { t.Helper() diff --git a/internal/servewaf/ratelimits_test.go b/internal/discriminator/ratelimits_test.go similarity index 95% rename from internal/servewaf/ratelimits_test.go rename to internal/discriminator/ratelimits_test.go index c119f83..c6cb085 100644 --- a/internal/servewaf/ratelimits_test.go +++ b/internal/discriminator/ratelimits_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -10,7 +10,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/requestlog" ) -// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's +// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's // limit_hit names it. const minute = "minute" @@ -75,7 +75,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { t.Parallel() - // With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two + // With SWW_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 ( @@ -118,7 +118,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWWAF_DENY_NETS + const denied = "192.0.2.50" // in SWW_DENY_NETS geojsURL, _ := startGeoJS(t) s, _, server := startWithClock(t, geojsURL, map[string]string{ @@ -151,7 +151,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { // /assets/, and breaks the limit. s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) - // A ban, SWWAF_DENY_NETS and the country lists still refuse a path + // A ban, SWW_DENY_NETS and the country lists still refuse a path // under a prefix. s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) diff --git a/internal/servewaf/reputation.go b/internal/discriminator/reputation.go similarity index 89% rename from internal/servewaf/reputation.go rename to internal/discriminator/reputation.go index 7a0bc10..0a855ce 100644 --- a/internal/servewaf/reputation.go +++ b/internal/discriminator/reputation.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "context" @@ -10,12 +10,12 @@ import ( "sneak.berlin/go/smallwebwaf/internal/reputation" ) -// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that +// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that // refuses the requests of a client a source lists. const deny = "deny" // blocklistDenied notes the blocklists that list the client, as -// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny, +// noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny, // refuses the request. Being limit, it lowers the client's limits instead // (see limitPercentages), and being log, it does nothing more. func (rq *request) blocklistDenied() bool { @@ -43,7 +43,7 @@ func (rq *request) crowdSecBanned(now time.Time) bool { } // dnsblDenied notes the DNSBL zones whose verdict lists the client, as -// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being +// noteListed does, and reports whether SWW_REPUTATION_ACTION, being // deny, refuses the request. 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 @@ -59,8 +59,8 @@ func (rq *request) dnsblDenied(ctx context.Context) bool { // abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when // its score of the client is a hit, and reports whether -// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied -// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A +// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied +// does for a zone. While SWW_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 @@ -81,7 +81,7 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool { rq.abuseIPDBHit = true rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, - "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE") + "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE") return rq.h.config.ReputationAction == deny } diff --git a/internal/servewaf/reputation_test.go b/internal/discriminator/reputation_test.go similarity index 93% rename from internal/servewaf/reputation_test.go rename to internal/discriminator/reputation_test.go index 0989375..cb5fd3e 100644 --- a/internal/servewaf/reputation_test.go +++ b/internal/discriminator/reputation_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "fmt" @@ -15,20 +15,20 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // The reputation settings. const ( - blocklistURLs = "SWWAF_BLOCKLIST_URLS" - blocklistAction = "SWWAF_BLOCKLIST_ACTION" - asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" + blocklistURLs = "SWW_BLOCKLIST_URLS" + blocklistAction = "SWW_BLOCKLIST_ACTION" + asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL" ) -// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION: +// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION: // limitHalf gives a listed client half of every limit, and limitQuarter a // quarter. const ( @@ -127,7 +127,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(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 SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor + // in SWW_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)) @@ -224,7 +224,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) }, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, { - "SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits", + "SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits", map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none, }, } { @@ -299,22 +299,22 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( labels := `{instance="` + alertInstance + `",source="` + listURL + `"}` if want.hits == 0 { - wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels) + wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels) } else { - wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits) + wantMetric(t, metrics, "sww_reputation_hits_total"+labels, want.hits) } - wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, + wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels, want.fetched) } } // The DNSBL settings. const ( - dnsblZones = "SWWAF_DNSBL_ZONES" - dnsblResolver = "SWWAF_DNSBL_RESOLVER" - reputationAction = "SWWAF_REPUTATION_ACTION" + dnsblZones = "SWW_DNSBL_ZONES" + dnsblResolver = "SWW_DNSBL_RESOLVER" + reputationAction = "SWW_REPUTATION_ACTION" ) // The DNSBL zones these tests name, which are never asked about the @@ -370,7 +370,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 SWWAF_DNSBL_ZONES names them. + // In the order SWW_DNSBL_ZONES names them. wantReputation(t, line, listedBy[from]...) wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, tc.percent) @@ -416,7 +416,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 SWWAF_ALLOW_NETS, is not checked at all: neither is noted + // fromDE, in SWW_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.ActionDenied), dropURL) @@ -537,9 +537,9 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted( for _, zone := range []string{dnsblZone, otherZone} { labels := `{instance="` + alertInstance + `",source="` + zone + `"}` - wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) - wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) - wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2) + wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) } } @@ -567,7 +567,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) { } metrics := s.scrape() - wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+ + wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+ alertInstance+`",source="`+masked+`"}`, 1) for name, shown := range map[string]string{ @@ -609,7 +609,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) { // The AbuseIPDB settings, and accountKey, the key the tests set. const ( - abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" + abuseIPDBKey = "SWW_ABUSEIPDB_KEY" accountKey = "abuseipdb-key-0123456789abcdef" ) @@ -617,7 +617,7 @@ const ( // AbuseIPDB. const abuseipdb = reputation.AbuseIPDBSource -// abuseIPDBURL is where newServeWAF has clients checked with AbuseIPDB: at +// abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at // abuseIPDBStandIn, which TestMain registers with Go's default transport, // through which AbuseIPDB is asked. const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check" @@ -800,7 +800,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey, reputationAction: tc.action, }) - // At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. + // At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. loadScores(server, map[string]int64{fromDE: 75, fromKP: 74}) for i := range 3 { @@ -851,7 +851,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 SWWAF_ABUSEIPDB_MIN_SCORE" || + waiting[0].Reason != "scored by AbuseIPDB at or over SWW_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 "+ @@ -863,10 +863,10 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted( metrics := s.scrape() labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}` - wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) - wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) - wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) - wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900) + wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2) + wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0) + wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900) } func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) { @@ -887,7 +887,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) { wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantAbuseIPDBChecks(t, server, 0) - wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+ + wantNoSeries(t, s.scrape(), `sww_reputation_daily_budget_remaining{`+ `instance="`+alertInstance+`",source="`+abuseipdb+`"}`) } @@ -922,7 +922,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) { abuseIPDBKey: accountKey, reputationAction: actionLog, }) // Every source lists client, and none otherClient, whose score is - // under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default. + // under SWW_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}) @@ -971,11 +971,11 @@ func verdictsFetched() time.Time { } // loadVerdicts puts into server's DNSBL, for each client listedBy names, -// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at +// a verdict of each zone SWW_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. -func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) { +func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) { verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones())) for client, zones := range listedBy { @@ -993,7 +993,7 @@ func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) { // loadScores puts into server's AbuseIPDB the score scores gives each // client, an IPv4 address, fetched at verdictsFetched, as reputation.json // would at start. -func loadScores(server *servewaf.Server, scores map[string]int64) { +func loadScores(server *discriminator.Server, scores map[string]int64) { kept := make([]reputation.Score, 0, len(scores)) for client, score := range scores { kept = append(kept, reputation.Score{ @@ -1007,7 +1007,7 @@ func loadScores(server *servewaf.Server, scores map[string]int64) { // wantAbuseIPDBChecks checks how many clients server has checked with // AbuseIPDB. -func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) { +func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) { t.Helper() if got := server.AbuseIPDB.Checked(); got != want { @@ -1017,7 +1017,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) { // loadLists puts copies of lists into server's lists, by URL, each with // its lines, fetched at listsFetched, as reputation.json would at start. -func loadLists(t *testing.T, server *servewaf.Server, copies map[string][]string) { +func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) { t.Helper() lists := make([]reputation.List, 0, len(copies)) diff --git a/internal/servewaf/request.go b/internal/discriminator/request.go similarity index 91% rename from internal/servewaf/request.go rename to internal/discriminator/request.go index 21f4ce1..1701b4d 100644 --- a/internal/servewaf/request.go +++ b/internal/discriminator/request.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "context" @@ -17,7 +17,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/rules" ) -// refusal is servewaf refusing a request: the status the visitor is to get, 0 +// refusal is discriminator refusing a request: the status the visitor is to get, 0 // to close its connection without an answer, and the action the log line names. type refusal struct { status int @@ -25,7 +25,7 @@ type refusal struct { } // decision is what the checks note of a request: its log line, and what -// counting the request needs once it has ended. servewaf keeps a copy of it +// counting the request needs once it has ended. discriminator keeps a copy of it // until the request's report comes. It holds no credentials, since the log line // never does, and neither the request's objects nor their bodies. type decision struct { @@ -34,7 +34,7 @@ type decision struct { // requestURI is the path with the query, as a ban's notes keep it. requestURI string // site is, for a question, the host the visitor asked for, which the - // alerts name, since one servewaf serves many sites. + // alerts name, since one discriminator serves many sites. site string // allowed is true for a question answered with DispositionAllow. allowed bool @@ -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. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it, + // found no answer kept. SWW_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 @@ -63,7 +63,7 @@ type decision struct { reputation []bans.ReputationHit } -// request is one request to servewaf, from the moment its headers have been +// request is one request to discriminator, from the moment its headers have been // read to its log line. type request struct { decision @@ -161,7 +161,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 checks of checkClient decide: a request they refuse -// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, +// is answered with SWW_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. func (rq *request) check(ctx context.Context) *refusal { @@ -185,17 +185,17 @@ func (rq *request) check(ctx context.Context) *refusal { // checkClient runs the checks on the request's client, and returns the // action of the first that refuses the request, or "" when none does. A -// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any -// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock, +// client in SWW_ALLOW_NETS skips them, and is not looked up. For any +// other client, SWW_DENY_NETS comes first, then a ban on its netblock, // so that a client either refuses is not looked up, then the lookup of // its AS number and country, then the country lists, then the blocklists, // then the CrowdSec decision list, which bans the client it lists, then // the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of // them refuses is not counted for the rate limits. Then come the -// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the -// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that +// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the +// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that // every other request is counted, each of them by the client's limit -// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header +// percentages, then SWW_TRAP_PATHS, then the rule files, then the header // limits, and last the Core Rule Set. A request exempt from the rate limits // is exempt from the byte limits too. ctx is the request's own context. func (rq *request) checkClient(ctx context.Context) string { @@ -266,8 +266,8 @@ func (rq *request) checkClient(ctx context.Context) string { } // countedByRateLimits reports whether the rate limits count the request: its -// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt -// under SWWAF_RATE_LIMIT_EXEMPT_PATHS. +// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt +// under SWW_RATE_LIMIT_EXEMPT_PATHS. func (rq *request) countedByRateLimits() bool { cfg := rq.h.config @@ -276,7 +276,7 @@ func (rq *request) countedByRateLimits() bool { } // pathExempt reports whether r is exempt under prefixes, -// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its +// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_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 @@ -378,9 +378,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 SWWAF_ALLOW_NETS or -// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in -// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its +// 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 // client's AS number when answerAtTheEnd gives one. With every anomaly // threshold off, the default, it does nothing. func (rq *request) countAnomalies() { @@ -425,7 +425,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) { return rq.h.geojs.Kept(rq.h.clientGroup(rq.client)) } -// responseWriter is servewaf's answer to a request. It notes the status and +// responseWriter is discriminator's answer to a request. It notes the status and // size for the log line, and the first error writing the answer. type responseWriter struct { http.ResponseWriter diff --git a/internal/servewaf/requestlog_test.go b/internal/discriminator/requestlog_test.go similarity index 95% rename from internal/servewaf/requestlog_test.go rename to internal/discriminator/requestlog_test.go index dff7a2e..0ebefa6 100644 --- a/internal/servewaf/requestlog_test.go +++ b/internal/discriminator/requestlog_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "io" @@ -10,15 +10,15 @@ import ( "testing" "time" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( // requestIDHeader carries the request's id. requestIDHeader = "X-Request-ID" - // instance is the SWWAF_INSTANCE_NAME a test sets. + // instance is the SWW_INSTANCE_NAME a test sets. instance = "fsn1app1/blog" // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // limits count it as. @@ -104,7 +104,7 @@ func wantFullLine(t *testing.T, line logLine) { headers := map[string]string{"accept": "text/html", "x-custom": "one, two"} want := withTimings(line, requestlog.Line{ - Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, + Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport, Time: line.Time, Instance: instance, ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure, Host: appHost, Path: "/full", Protocol: protocol, @@ -130,7 +130,7 @@ func wantBareLine(t *testing.T, line logLine) { t.Helper() want := withTimings(line, requestlog.Line{ - Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, + Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport, Time: line.Time, Instance: instance, ClientIP: localhost, Method: http.MethodPost, Scheme: plain, Host: appHost, Path: "/bare", Protocol: protocol, @@ -195,7 +195,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) { t.Parallel() - const denied = "192.0.2.50" // in SWWAF_DENY_NETS + const denied = "192.0.2.50" // in SWW_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 @@ -220,7 +220,7 @@ func TestTimingsAreInOrder(t *testing.T) { wantStatus(t, do(t, req), http.StatusForbidden) refused := out.endedLines(t, 2)[1] - wantStatus(t, get(t, server.Addr, servewaf.HealthPath), http.StatusOK) + wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK) health := out.endedLines(t, 3)[2] // A request passed to the app has every timing; one refused, none of diff --git a/internal/servewaf/rulefiles_test.go b/internal/discriminator/rulefiles_test.go similarity index 85% rename from internal/servewaf/rulefiles_test.go rename to internal/discriminator/rulefiles_test.go index 7858327..e6c9b96 100644 --- a/internal/servewaf/rulefiles_test.go +++ b/internal/discriminator/rulefiles_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -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 SWWAF_BAN_RESPONSE is, and + // A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and // bans no one. line = s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked) wantRuleIDs(t, line, "noted", "blocked") s.get(client, http.StatusOK, requestlog.ActionForward) - // A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for + // A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for // seven days, the default. line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned) wantRuleIDs(t, line, "noted", "probe") @@ -101,7 +101,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { attackBanDuration: "1h", }) - // The first probe bans for SWWAF_ATTACK_BAN_DURATION. + // The first probe bans for SWW_ATTACK_BAN_DURATION. line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) @@ -122,8 +122,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { t.Parallel() const ( - allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS - exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS + allowed = "192.0.2.60" // in SWW_ALLOW_NETS + exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS ) s, _, server := startWithClock(t, "", map[string]string{ @@ -133,7 +133,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) { rateLimitPerMinute: "1", }) - // A client in SWWAF_ALLOW_NETS is not checked. + // A client in SWW_ALLOW_NETS is not checked. line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) wantRuleIDs(t, line) @@ -196,18 +196,18 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) { metrics := s.scrape() wantMetric(t, metrics, - `smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1) + `sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1) wantMetric(t, metrics, - `smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) - wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2) - wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+ + `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) + wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2) + wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+ `instance="app",status_class="4xx"}`, 1) wantMetric(t, metrics, - `smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1) - wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1) - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1) - wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0) - wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) + `sww_offences_total{instance="app",kind="rule_blocked"}`, 1) + wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1) + wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1) + wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0) + wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1) } // writeRules writes content as a rule file into a new directory, and diff --git a/internal/servewaf/rules.go b/internal/discriminator/rules.go similarity index 95% rename from internal/servewaf/rules.go rename to internal/discriminator/rules.go index f425528..dc7e8e8 100644 --- a/internal/servewaf/rules.go +++ b/internal/discriminator/rules.go @@ -1,4 +1,4 @@ -package servewaf +package discriminator import ( "slices" @@ -10,7 +10,7 @@ import ( ) // trapPath reports whether the request asks for a path in -// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query, +// SWW_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 ban it would have diff --git a/internal/servewaf/staticlists_test.go b/internal/discriminator/staticlists_test.go similarity index 94% rename from internal/servewaf/staticlists_test.go rename to internal/discriminator/staticlists_test.go index 3ddffad..9ba5a12 100644 --- a/internal/servewaf/staticlists_test.go +++ b/internal/discriminator/staticlists_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) { calls.Add(1) }) geojsURL, asked := startGeoJS(t) - // fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which + // fromKP is in SWW_ALLOW_NETS, and in SWW_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 SWWAF_DENY_NETS, the country lists nor the limit of one + // Neither SWW_DENY_NETS, the country lists nor the limit of one // request a minute refuses the client, and its country is not looked // up. wantAnswers(t, addr, out, []sentRequest{ @@ -157,9 +157,9 @@ type sentRequest struct { action string } -// wantAnswers sends requests to smallwebwaf at addr one after another and +// wantAnswers sends requests to sww at addr one after another and // checks each one's answer and log line. They must be the first requests -// smallwebwaf is sent, since the log lines are matched to them in order. +// sww is sent, since the log lines are matched to them in order. func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) { t.Helper() diff --git a/internal/servewaf/trappaths_test.go b/internal/discriminator/trappaths_test.go similarity index 93% rename from internal/servewaf/trappaths_test.go rename to internal/discriminator/trappaths_test.go index 14be397..e3222a4 100644 --- a/internal/servewaf/trappaths_test.go +++ b/internal/discriminator/trappaths_test.go @@ -1,4 +1,4 @@ -package servewaf_test +package discriminator_test import ( "net/http" @@ -14,14 +14,14 @@ import ( // trapPaths is the setting's name, and trapPathList what the tests set it // to. const ( - trapPaths = "SWWAF_TRAP_PATHS" + trapPaths = "SWW_TRAP_PATHS" trapPathList = "/wp-login.php,/xmlrpc.php" ) func TestTrapPathBansAsABanRuleDoes(t *testing.T) { t.Parallel() - const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS + const allowed = "192.0.2.60" // in SWW_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 SWWAF_ALLOW_NETS is not checked. + // A client in SWW_ALLOW_NETS is not checked. s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) // The query is not part of the path. @@ -103,8 +103,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ - trapPaths: trapPathList, - "SWWAF_RULES_ENABLED": "false", + trapPaths: trapPathList, + "SWW_RULES_ENABLED": "false", }) s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned) diff --git a/internal/lookup/file.go b/internal/lookup/file.go index d81bbeb..841f5cc 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 - // (SWWAF_LOOKUP_DB_PATH). + // (SWW_LOOKUP_DB_PATH). Path string // Now tells the time, normally time.Now. Now func() time.Time @@ -51,7 +51,7 @@ type File struct { } // record is what the lookup database holds about a network, of the fields -// smallwebwaf reads. +// sww reads. type record struct { ASN string `maxminddb:"asn"` ASName string `maxminddb:"as_name"` @@ -221,12 +221,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("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err) + return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err) } reader, err := maxminddb.OpenBytes(data) if err != nil { - return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) + return nil, fmt.Errorf("SWW_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 307b566..02f7b0d 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: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing + + missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing + ": no such file or directory", - notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase + + notDatabase: "SWW_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": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + + "error": "SWW_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 c483529..8cdd15d 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 -// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at +// SWW_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 - // (SWWAF_LOOKUP_TIMEOUT). + // (SWW_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/lookup/lookuptest/lookuptest.go b/internal/lookup/lookuptest/lookuptest.go index 4ea85ae..6fa3a28 100644 --- a/internal/lookup/lookuptest/lookuptest.go +++ b/internal/lookup/lookuptest/lookuptest.go @@ -17,7 +17,7 @@ import ( const fileMode = 0o600 // Network is what a lookup database holds about a netblock, of the fields -// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and +// sww reads: its AS number, such as AS64496, the AS's name, and // its country, such as DE. type Network struct { ASN string diff --git a/internal/metrics/busiest.go b/internal/metrics/busiest.go index f0c69cd..82148cf 100644 --- a/internal/metrics/busiest.go +++ b/internal/metrics/busiest.go @@ -41,7 +41,7 @@ type busiest struct { // for the topN busiest countries. func newCountries(topN int) *busiest { countries := newBusiest(topN, "country", "the client's country") - countries.refused = counterVec("smallwebwaf_country_list_refusals_total", + countries.refused = counterVec("sww_country_list_refusals_total", "Requests the country lists refused, by the client's country.", []string{"country"}) @@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest { return &busiest{ topN: topN, - requests: counterVec("smallwebwaf_"+label+"_requests_total", + requests: counterVec("sww_"+label+"_requests_total", "Requests, by "+description+".", by), - requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total", + requestBytes: counterVec("sww_"+label+"_request_bytes_total", "Request body bytes, by "+description+".", by), - responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total", + responseBytes: counterVec("sww_"+label+"_response_bytes_total", "Response body bytes, by "+description+".", by), seen: map[string]int64{}, top: map[string]bool{}, diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 1b14c0a..4adff7b 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,4 +1,4 @@ -// Package metrics keeps smallwebwaf's Prometheus metrics, as +// Package metrics keeps sww's Prometheus metrics, as // docs/metrics.md lists them, and serves them in the Prometheus text // format, as the "Metrics endpoint" section of SPEC.md requires. No // metric carries a client's address. @@ -22,7 +22,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/rules" ) -// Metrics are smallwebwaf's metrics. They are safe for concurrent use. +// Metrics are sww's metrics. They are safe for concurrent use. type Metrics struct { // registry gives every metric registered with it the label instance. registry prometheus.Registerer @@ -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 (SWWAF_METRICS_TOP_N). Every metric carries instanceName -// (SWWAF_INSTANCE_NAME) as its label instance. +// own (SWW_METRICS_TOP_N). Every metric carries instanceName +// (SWW_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 { @@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics { prometheus.Labels{"instance": instanceName}, registry), handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), inFlight: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "smallwebwaf_requests_in_flight", + Name: "sww_requests_in_flight", Help: "Requests under way.", }), - requests: counterVec("smallwebwaf_requests_total", + requests: counterVec("sww_requests_total", "Requests, by the class of their status and their action.", byStatus), - requestBytes: counterVec("smallwebwaf_request_bytes_total", + requestBytes: counterVec("sww_request_bytes_total", "Request body bytes, by the class of the status and the action.", byStatus), - responseBytes: counterVec("smallwebwaf_response_bytes_total", + responseBytes: counterVec("sww_response_bytes_total", "Response body bytes, by the class of the status and the action.", byStatus), requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ - Name: "smallwebwaf_request_duration_seconds", + Name: "sww_request_duration_seconds", Help: "How long requests took, from their arrival to their end.", }), upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ - Name: "smallwebwaf_upstream_duration_seconds", + Name: "sww_upstream_duration_seconds", Help: "How long requests passed to the app took, from then to their end.", }), - rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total", + rateLimitHits: counterVec("sww_rate_limit_hits_total", "Requests that broke a rate limit, a byte limit or the error burst, by "+ "its window and its kind, requests, bytes or refusals.", []string{"window", "kind"}), - offences: counterVec("smallwebwaf_offences_total", + offences: counterVec("sww_offences_total", "Offences, by kind.", []string{"kind"}), - wafMatches: counterVec("smallwebwaf_waf_matches_total", - "Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+ + wafMatches: counterVec("sww_waf_matches_total", + "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+ "and the rule's id.", []string{"mode", "rule_id"}), countries: newCountries(topN), asns: newASNs(topN), GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "smallwebwaf_geojs_requests_total", + Name: "sww_geojs_requests_total", Help: "Requests to GeoJS.", }), GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "smallwebwaf_geojs_failures_total", + Name: "sww_geojs_failures_total", Help: "Requests to GeoJS that failed.", }), GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "smallwebwaf_geojs_unanswered_total", + Name: "sww_geojs_unanswered_total", Help: "Requests that needed their client's answer from GeoJS and " + "went on without it, because GeoJS had not given it in time.", }), - stateFileWrites: counterVec("smallwebwaf_state_file_writes_total", + stateFileWrites: counterVec("sww_state_file_writes_total", "Writes of each state file.", byFile), - stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total", + stateFileWriteFailures: counterVec("sww_state_file_write_failures_total", "Writes of each state file that failed.", byFile), - stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds", + stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds", "When each state file was last written, in seconds since 1970.", byFile), - stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes", + stateFileSize: gaugeVec("sww_state_file_size_bytes", "The size of each state file, as it was last written.", byFile), - stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total", + stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total", "Edits of each state file taken in while running.", byFile), - stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total", + stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total", "Edits of each state file renamed to .bad because they did not parse.", byFile), } @@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients( bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec, } { m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_bans_made_total", + Name: "sww_bans_made_total", Help: "Bans made, by cause.", ConstLabels: prometheus.Labels{"cause": cause}, }, func() float64 { @@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients( m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_active_bans", + Name: "sww_active_bans", Help: "Bans active now, the permanent ones included.", }, func() float64 { active, _ := ledger.Count(now()) @@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients( return float64(active) }), prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_permanent_bans", + Name: "sww_permanent_bans", Help: "Permanent bans not lifted.", }, func() float64 { _, permanent := ledger.Count(now()) @@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients( return float64(permanent) }), prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_tracked_clients", + Name: "sww_tracked_clients", Help: "Clients in the table of clients.", }, func() float64 { return float64(limiter.Len()) @@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients( // ruleFiles, read as the metrics are asked for. It is called once, before // RuleMatched. func (m *Metrics) AddRules(ruleFiles *rules.Files) { - m.ruleMatches = counterVec("smallwebwaf_rule_matches_total", + m.ruleMatches = counterVec("sww_rule_matches_total", "Requests that matched a rule of the rule files, by its id and action.", []string{"rule_id", "action"}) m.registry.MustRegister(m.ruleMatches, prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_rules_loaded", + Name: "sww_rules_loaded", Help: "Rules loaded from the rule files.", }, func() float64 { return float64(ruleFiles.Len()) @@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) { } // AddRemoteLog adds the metrics of sending the log lines to -// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the +// SWW_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: "smallwebwaf_remote_log_lines_sent_total", - Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.", + Name: "sww_remote_log_lines_sent_total", + Help: "Log lines sent to SWW_LOG_REMOTE_URL.", }, func() float64 { return float64(remote.Sent()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_remote_log_lines_dropped_total", + Name: "sww_remote_log_lines_dropped_total", Help: "Log lines dropped: the oldest in a full buffer, and those " + "whose sending failed.", }, func() float64 { return float64(remote.Dropped()) }), prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_remote_log_buffer_depth", + Name: "sww_remote_log_buffer_depth", Help: "Log lines in the buffer, waiting to be sent.", }, func() float64 { return float64(remote.Depth()) @@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) { m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds", + Name: "sww_lookup_database_last_read_timestamp_seconds", Help: "When the lookup database in use was read, in seconds since 1970.", }, func() float64 { return float64(lastRead().Unix()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_lookup_database_read_failures_total", + Name: "sww_lookup_database_read_failures_total", Help: "Replacements of the lookup database that could not be read.", }, func() float64 { return float64(readFailures()) @@ -275,7 +275,7 @@ const sourceLabel = "source" // copy in use was fetched, and for a zone, the queries made and those that // failed. It is called once, before ReputationHit. func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) { - m.reputationHits = counterVec("smallwebwaf_reputation_hits_total", + m.reputationHits = counterVec("sww_reputation_hits_total", "Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+ "zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.", []string{sourceLabel}) @@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL m.addReputationFailures(source, func() int { return lists.Failures(listURL) }) m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds", + Name: "sww_reputation_last_fetch_timestamp_seconds", Help: "When the copy of the list in use was fetched, in seconds since " + "1970, or 0 while there is none.", ConstLabels: source, @@ -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 SWWAF_ABUSEIPDB_KEY is set. +// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set. func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource} @@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { m.addReputationFailures(source, abuseIPDB.Failures) m.registry.MustRegister( prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "smallwebwaf_reputation_daily_budget_remaining", - Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.", + Name: "sww_reputation_daily_budget_remaining", + Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.", ConstLabels: source, }, func() float64 { return float64(abuseIPDB.BudgetLeft()) @@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) { m.registry.MustRegister( prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_alerts_sent_total", + Name: "sww_alerts_sent_total", Help: "Alerts the destination took.", ConstLabels: destination, }, func() float64 { return float64(queue.Counts(name).Sent) }), prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_alerts_failed_total", + Name: "sww_alerts_failed_total", Help: "Requests to the destination that failed.", ConstLabels: destination, }, func() float64 { return float64(queue.Counts(name).Failed) }), prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_alerts_suppressed_total", - Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " + - "alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.", + 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.", ConstLabels: destination, }, func() float64 { return float64(queue.Suppressed()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_alerts_dropped_total", + Name: "sww_alerts_dropped_total", Help: "Alerts dropped, the oldest first, from a full queue, and " + "alerts given up as the destination refused them.", ConstLabels: destination, @@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) { } // WAFMatched counts a request that matched the Core Rule Set's rule id, -// with SWWAF_WAF_MODE at mode. +// with SWW_WAF_MODE at mode. func (m *Metrics) WAFMatched(mode string, id int) { m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() } @@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) { } // StateFileEditTakenIn counts an admin's edit of the state file name -// taken in while smallwebwaf runs. +// taken in while sww runs. func (m *Metrics) StateFileEditTakenIn(name string) { m.stateFileEditsTakenIn.WithLabelValues(name).Inc() } @@ -506,7 +506,7 @@ func (m *Metrics) count( // zone, or of the checks of clients with AbuseIPDB, which count tells. func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) { m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_reputation_queries_total", + Name: "sww_reputation_queries_total", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.", ConstLabels: source, }, func() float64 { @@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in // that failed, which count tells. func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) { m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ - Name: "smallwebwaf_reputation_failures_total", + Name: "sww_reputation_failures_total", Help: "Fetches of the list, queries to the DNSBL zone, or checks with " + "AbuseIPDB, that failed.", ConstLabels: source, diff --git a/internal/ratelimit/history_test.go b/internal/ratelimit/history_test.go index 2cd83f5..853e25f 100644 --- a/internal/ratelimit/history_test.go +++ b/internal/ratelimit/history_test.go @@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) { {Forwarded: true, Status: 502, ResponseBytes: 12}, // Closed without an answer: refused, and no response. {Refused: true, Status: 0}, - // Answered 404 for a path under /_smallwebwaf/: neither forwarded nor + // Answered 404 for a path under /_sww/: neither forwarded nor // refused. {Status: 404}, } { diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index 49c8294..0b3c7ca 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 SWWAF_MAX_TRACKED_CLIENTS clients are +// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are // kept, in memory, and written to clients.json and read from it by the // state package. package ratelimit @@ -25,7 +25,7 @@ const ( KindRequests = "requests" // KindBytes is a byte limit, on a client's bytes. KindBytes = "bytes" - // KindRefusals is the error burst, on a client's requests smallwebwaf + // KindRefusals is the error burst, on a client's requests sww // refused after a rule file match or for a missing or wrong token. KindRefusals = "refusals" ) @@ -92,9 +92,9 @@ type History struct { ASName string `json:"as_name,omitempty"` Country string `json:"country,omitempty"` LookedUp time.Time `json:"looked_up,omitzero"` - // Requests are all the client's requests: Forwarded those servewaf allowed, + // Requests are all the client's requests: Forwarded those discriminator allowed, // Refused those it refused, and neither those for a path under - // /_smallwebwaf/. + // /_sww/. Requests int64 `json:"requests"` Forwarded int64 `json:"forwarded"` Refused int64 `json:"refused"` @@ -135,8 +135,8 @@ type Offences struct { // Request is what a client's history keeps of one of its requests. type Request struct { - // Forwarded is true for a request servewaf allowed, and Refused for one it - // refused. Both are false for a request for a path under /_smallwebwaf/. + // Forwarded is true for a request discriminator allowed, and Refused for one it + // refused. Both are false for a request for a path under /_sww/. Forwarded bool Refused bool // Status is what the client was sent, 0 if nothing was. @@ -156,7 +156,7 @@ type Request struct { } // New returns a Limiter for limits, with no client counted yet, whose -// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past +// table holds at most maxClients clients (SWW_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 { @@ -258,7 +258,7 @@ func (l *Limiter) CountBytes( return l.count(client, now, 0, bytes, percent) } -// CountRefusal counts a request from client at now that smallwebwaf +// CountRefusal counts a request from client at now that sww // refused after a rule file or Core Rule Set match or for a missing or // wrong token, and reports whether the client's refusals in the minute // that ends at now, this one included, are more than threshold, which diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go index 5791f48..04424d6 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 -// SWWAF_MAX_TRACKED_CLIENTS. +// SWW_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 d40239b..0a2eb47 100644 --- a/internal/remotelog/remotelog.go +++ b/internal/remotelog/remotelog.go @@ -1,5 +1,5 @@ -// Package remotelog sends the lines smallwebwaf writes on stdout to the -// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section +// Package remotelog sends the lines sww writes on stdout to the +// remote log endpoint, SWW_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 SWWAF_LOG_REMOTE_URL, by its scheme. +// The forms of SWW_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 (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or + // URL is the endpoint (SWW_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 (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's. + // must chain to (SWW_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 - // (SWWAF_LOG_REMOTE_BUFFER). + // (SWW_LOG_REMOTE_BUFFER). Buffer int // Facility is the number of the records' syslog facility - // (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME - // (SWWAF_LOG_REMOTE_APP_NAME). + // (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME + // (SWW_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 SWWAF_LOG_REMOTE_URL failed", + processLog.Warn("sending to SWW_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 b910de1..6d9a9b0 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 SWWAF_LOG_REMOTE_URL failed" { + if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" { t.Fatalf("logged %q", line) } @@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { template := &x509.Certificate{ SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "smallwebwaf test CA"}, + Subject: pkix.Name{CommonName: "sww test CA"}, NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC), IsCA: true, diff --git a/internal/reputation/abuseipdb.go b/internal/reputation/abuseipdb.go index 36c8741..b7de34b 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 (SWWAF_ABUSEIPDB_KEY). + // the account's key (SWW_ABUSEIPDB_KEY). URL string Key string - // MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), + // MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE), // and DailyBudget the most checks made in a day, in UTC - // (SWWAF_ABUSEIPDB_DAILY_BUDGET). + // (SWW_ABUSEIPDB_DAILY_BUDGET). MinScore int64 DailyBudget int // CacheTTL is how long a score is used after it was fetched - // (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take - // (SWWAF_REPUTATION_TIMEOUT). + // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take + // (SWW_REPUTATION_TIMEOUT). CacheTTL time.Duration Timeout time.Duration // Now tells the time, normally time.Now in UTC. diff --git a/internal/reputation/abuseipdb_test.go b/internal/reputation/abuseipdb_test.go index 4297d61..70460d2 100644 --- a/internal/reputation/abuseipdb_test.go +++ b/internal/reputation/abuseipdb_test.go @@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) { "failures_total": "1", "daily_budget_remaining": "3", } { - line := "\nsmallwebwaf_reputation_" + series + + line := "\nsww_reputation_" + series + `{instance="app",source="abuseipdb"} ` + want + "\n" if !strings.Contains(scraped, line) { t.Errorf("metrics\n%s\nwant%s", scraped, line) @@ -581,7 +581,7 @@ func wantScore( // hitFrom is checker's Hit for a request from address, offender or not. // Its client is address for an IPv4 address, and its /64 for an IPv6 one, -// as smallwebwaf counts clients. +// as sww counts clients. func hitFrom( t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool, ) (int64, bool) { diff --git a/internal/reputation/dnsbl.go b/internal/reputation/dnsbl.go index b85a146..83cd2a4 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 - // (SWWAF_DNSBL_ZONES). + // (SWW_DNSBL_ZONES). Zones []string // Resolver is the resolver they are asked through - // (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the + // (SWW_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 - // (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take - // (SWWAF_REPUTATION_TIMEOUT). + // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take + // (SWW_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 SWWAF_DNSBL_ZONES names them. +// Zones returns the zones, in the order SWW_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 SWWAF_DNSBL_ZONES names them, each with its key +// lists it, in the order SWW_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 SWWAF_DNSBL_ZONES does not name, and, +// keeps, but for those of a zone SWW_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 700b78e..bdf4f98 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' SWWAF_REPUTATION_CACHE_TTL, and timeout their - // SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf + // cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their + // SWW_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 @@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) { "queries_total" + `{instance="app",source="` + otherZone + `"}`: "1", "failures_total" + `{instance="app",source="` + otherZone + `"}`: "1", } { - line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n" + line := "\nsww_reputation_" + series + " " + want + "\n" if !strings.Contains(scraped.Body.String(), line) { t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line) } @@ -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 SWWAF_DNSBL_ZONES does not name. + // zone SWW_DNSBL_ZONES does not name. inUse := reputation.Verdict{ Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: now.Add(-cacheTTL + time.Nanosecond), @@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing. } //nolint:paralleltest // one at a time, as the comment at the top of this file says -func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) { +func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) { resolver := &resolverStandIn{answers: map[string]answer{ listedName: {addrs: []string{listing}}, }} diff --git a/internal/reputation/reputation.go b/internal/reputation/reputation.go index eb55148..a7a8421 100644 --- a/internal/reputation/reputation.go +++ b/internal/reputation/reputation.go @@ -1,10 +1,10 @@ // Package reputation fetches the lists the settings name by URL: the -// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines -// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec -// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of +// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines +// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec +// engine SWW_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 -// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks +// SWW_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. @@ -66,18 +66,18 @@ type List struct { // Params are what New needs. type Params struct { - // BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and + // BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and // ASNLimitPercentURL the file of AS:percent lines - // (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset. + // (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset. BlocklistURLs []string ASNLimitPercentURL string // CrowdSecDecisionsURL is the CrowdSec decision list, "" while - // SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey - // (SWWAF_CROWDSEC_LAPI_KEY). + // SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey + // (SWW_CROWDSEC_LAPI_KEY). CrowdSecDecisionsURL string CrowdSecKey string // Refresh is how long after a list was last fetched or tried it is - // fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision + // fetched again (SWW_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. @@ -152,7 +152,7 @@ func New(params Params) *Lists { } // URLs returns the URL of every list: the blocklists' in the order -// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then +// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then // the CrowdSec decision list's. func (l *Lists) URLs() []string { urls := slices.Clone(l.params.BlocklistURLs) @@ -168,7 +168,7 @@ func (l *Lists) URLs() []string { } // ListedBy returns the URLs of the blocklists whose copy lists addr, in -// the order SWWAF_BLOCKLIST_URLS names them. +// the order SWW_BLOCKLIST_URLS names them. func (l *Lists) ListedBy(addr netip.Addr) []string { l.mu.Lock() defer l.mu.Unlock() @@ -371,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time { // fetch fetches the list at listURL, and notes the try. A good copy takes // the place of the one held. A failure leaves that in use, and is counted, // logged and raised as a source_failure alert. A fetch cut off as ctx -// ends, as smallwebwaf stops, is no failure, but is still noted as a try, +// ends, as sww stops, is no failure, but is still noted as a try, // so that a restart waits for it: the server may have had its request. func (l *Lists) fetch(ctx context.Context, listURL string) { lines, err := l.get(ctx, listURL) @@ -551,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) { } // parsePercents reads the lines of the file of AS:percent lines, each an -// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An +// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An // AS number listed more than once gets the lowest of its percentages. func parsePercents(lines []string) (entries, error) { found := entries{percents: map[string]int64{}} diff --git a/internal/reputation/reputation_test.go b/internal/reputation/reputation_test.go index 3d858e2..3a9011d 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' SWWAF_BLOCKLIST_REFRESH, and cooldown their - // SWWAF_ALERT_COOLDOWN, longer than it. + // refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their + // SWW_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 SWWAF_BLOCKLIST_URLS names them. + // In the order SWW_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 c45e821..ec890ea 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -1,6 +1,6 @@ -// Package requestlog writes the lines smallwebwaf prints on stdout: one -// JSON object per request, two from servewaf and a short one from rproxy, marked -// "type":"request", and the process's own messages as JSON lines marked +// Package requestlog writes the lines sww prints on stdout: one +// JSON object per request, two from discriminator and a short one from rproxy, +// marked "type":"request", and the process's own messages as JSON lines marked // "type":"process". package requestlog @@ -14,10 +14,10 @@ import ( "sneak.berlin/go/smallwebwaf/internal/ratelimit" ) -// The action a request line names: what servewaf did with the request. +// The action a request line names: what discriminator did with the request. const ( - // ActionForward is a request servewaf allowed, which rproxy passes to the - // app. + // ActionForward is a request discriminator allowed, which rproxy passes to + // the app. ActionForward = "forward" // ActionTooLarge is a request over the header limits. ActionTooLarge = "too_large" @@ -33,17 +33,17 @@ const ( // rule. ActionRuleBlocked = "rule_blocked" // ActionWAFBlocked is a request refused because the Core Rule Set - // scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. + // scored it at or over SWW_WAF_ANOMALY_THRESHOLD. ActionWAFBlocked = "waf_blocked" // ActionDenied is a request refused because its client is in - // SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, + // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while - // SWWAF_REPUTATION_ACTION is deny. + // SWW_REPUTATION_ACTION is deny. ActionDenied = "denied" // ActionCountryDenied is a request refused for its client's country. ActionCountryDenied = "country_denied" - // ActionAdmin is a request servewaf answered at one of its own endpoints, - // or a question about a path under /_smallwebwaf/. + // ActionAdmin is a request discriminator answered at one of its own + // endpoints, or a question about a path under /_sww/. ActionAdmin = "admin" ) @@ -56,14 +56,14 @@ const OffenceLimit = "limit" // that broke the error burst. const LimitHitErrorBurst = "error_burst" -// The servewaf_line a request line names, which says which of servewaf's two -// lines about a question it is. +// The discriminator_line a request line names, which says which of +// discriminator's two lines about a question it is. const ( - // ServeWAFLineDecision is servewaf's line at its decision. - ServeWAFLineDecision = "decision" - // ServeWAFLineReport is servewaf's line when rproxy's report comes, with - // the decision's fields and the report's together. - ServeWAFLineReport = "report" + // DiscriminatorLineDecision is discriminator's line at its decision. + DiscriminatorLineDecision = "decision" + // DiscriminatorLineReport is discriminator's line when rproxy's report + // comes, with the decision's fields and the report's together. + DiscriminatorLineReport = "report" ) // timeLayout is RFC 3339 with milliseconds. @@ -76,9 +76,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00" //nolint:tagliatelle // docs/request-log.md names the fields in snake_case type Line struct { Type string `json:"type"` - // ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's - // two lines about a question. - ServeWAFLine string `json:"servewaf_line,omitempty"` + // DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport + // on discriminator's two lines about a question. + DiscriminatorLine string `json:"discriminator_line,omitempty"` // The standard web log fields. Scheme is how the client reached rproxy, or // the trusted proxy in front of it. @@ -98,7 +98,7 @@ type Line struct { UserAgent string `json:"user_agent"` // Request detail. RequestID is the id rproxy made for the request, or for a - // request to servewaf's own endpoints a new one. ForwardedFor is the + // request to discriminator's own endpoints a new one. ForwardedFor is the // X-Forwarded-For header as received. ClientGroup is the netblock the // client is counted as. ASN, ASName and Country are the client's AS // number, AS name and country, as looked up. @@ -112,14 +112,14 @@ 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 SWWAF_LOG_REQUEST_HEADERS names that + // RequestHeaders are the headers SWW_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"` HasCookie bool `json:"has_cookie,omitempty"` - // Response detail, of servewaf's own answer. Aborted is true when rproxy, or - // the admin's tool, went away before the answer. + // Response detail, of discriminator's own answer. Aborted is true when + // rproxy, or the admin's tool, went away before the answer. ResponseContentType string `json:"response_content_type,omitempty"` Aborted bool `json:"aborted,omitempty"` @@ -128,7 +128,7 @@ type Line struct { // WouldAction is, in observe mode, the action enforce mode would have // taken with a request it would have refused: ActionDenied, // ActionBanned, ActionCountryDenied, ActionRateLimited, - // ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge + // ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge // for the header limits. WouldAction string `json:"would_action,omitempty"` // LimitPercent and LimitPercentSetting are, for a request the rate @@ -201,7 +201,7 @@ type RProxyLine struct { // if that is sooner. DurationResponse runs from then until the answer ends. DurationRequest float64 `json:"duration_request"` DurationResponse float64 `json:"duration_response"` - // Disposition is the valid disposition servewaf answered or, while + // Disposition is the valid disposition discriminator answered or, while // DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" // when neither gave one. Disposition string `json:"disposition,omitempty"` @@ -250,8 +250,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, SWWAF_INSTANCE_NAME, as instance. -// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; +// 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; // the request lines Write writes are never held back. func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ diff --git a/internal/rproxy/client.go b/internal/rproxy/client.go index a79f3e9..21476ce 100644 --- a/internal/rproxy/client.go +++ b/internal/rproxy/client.go @@ -18,7 +18,7 @@ const ( ) // peerAddress is the address of the request's TCP peer, normally traefik, without -// any IPv6 zone, which servewaf refuses. +// any IPv6 zone, which discriminator refuses. func peerAddress(r *http.Request) netip.Addr { addrPort, err := netip.ParseAddrPort(r.RemoteAddr) if err != nil { @@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr { // address outside them is the visitor; if every address in it is inside, the // leftmost is, and with no header, the peer. An entry that is not an address // ends the reading, since nothing to its left can be believed. The visitor's -// address never has an IPv6 zone, which servewaf refuses. +// address never has an IPv6 zone, which discriminator refuses. func clientAddress( peer netip.Addr, forwardedFor []string, trusted []netip.Prefix, ) netip.Addr { diff --git a/internal/rproxy/servewaf.go b/internal/rproxy/discriminator.go similarity index 70% rename from internal/rproxy/servewaf.go rename to internal/rproxy/discriminator.go index 2c2e601..d5a5832 100644 --- a/internal/rproxy/servewaf.go +++ b/internal/rproxy/discriminator.go @@ -11,15 +11,15 @@ import ( "net/http" "strings" - "sneak.berlin/go/smallwebwaf/internal/servewaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" ) -// answerBodyMaxBytes is the most of the body of servewaf's answer that is read. +// answerBodyMaxBytes is the most of the body of discriminator's answer that is read. // It is far above any answer's. const answerBodyMaxBytes = 1 << 20 // reportAnswerMaxBytes is the most of the answer to a report that is read, -// which has no body when servewaf takes the report. +// which has no body when discriminator takes the report. const reportAnswerMaxBytes = 4 << 10 var ( @@ -27,21 +27,22 @@ var ( errReportRefused = errors.New("the report was answered") ) -// servewafAnswer is what servewaf answered to a question. -type servewafAnswer struct { +// discriminatorAnswer is what discriminator answered to a question. +type discriminatorAnswer struct { status int header http.Header body []byte } -// noteAnswer notes what servewaf answered. An answer without a valid disposition -// notes nothing, and no report is due for it: servewaf holds no decision to report +// noteAnswer notes what discriminator answered. An answer without a valid disposition +// notes nothing, and no report is due for it: discriminator holds no decision to report // on. -func (rq *request) noteAnswer(answer *servewafAnswer) { - disposition := answer.header.Get(servewaf.DispositionHeader) +func (rq *request) noteAnswer(answer *discriminatorAnswer) { + disposition := answer.header.Get(discriminator.DispositionHeader) switch disposition { - case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose: + case discriminator.DispositionAllow, discriminator.DispositionDeny, + discriminator.DispositionClose: rq.disposition = disposition rq.answered = true rq.asn = answer.header.Get(asnHeader) @@ -54,27 +55,27 @@ func (rq *request) noteAnswer(answer *servewafAnswer) { func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { switch { case ctx.Err() != nil: - // The visitor went away while servewaf was being asked. servewaf has not + // The visitor went away while discriminator was being asked. discriminator has not // failed, and nothing is noted. This panic has Go's server close the // connection without an answer, and log nothing; the deferred finish still // runs. panic(http.ErrAbortHandler) case errors.Is(err, errCannotPassOn): - rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error()) + rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error()) rq.answer(http.StatusBadGateway) default: - // servewaf cannot be connected to, does not answer at all, or does not answer - // within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past - // SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets + // 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 - // whenever servewaf is slow. Making such requests hard to send is + // whenever discriminator is slow. Making such requests hard to send is // https://git.eeqj.de/sneak/smallwebwaf/issues/182. - rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error()) + rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error()) rq.disposition = rq.h.config.DefaultDisposition rq.byDefault = true - if rq.disposition == servewaf.DispositionAllow { + if rq.disposition == discriminator.DispositionAllow { rq.passToApp(ctx) } else { rq.answer(http.StatusServiceUnavailable) @@ -82,10 +83,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { } } -// ask sends servewaf the question, and returns its answer, which must have come +// 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 // away. -func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { +func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) { ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) defer cancel() @@ -94,7 +95,7 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { return nil, err } - res, err := rq.h.serveWAFTransport.RoundTrip(question) + res, err := rq.h.discriminatorTransport.RoundTrip(question) if err != nil { return nil, fmt.Errorf("send the question: %w", err) } @@ -119,17 +120,17 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn) } - return &servewafAnswer{res.StatusCode, res.Header, body}, nil + return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil } -// canBeAsked reports whether Go's client can send servewaf r as the visitor sent +// canBeAsked reports whether Go's client can send discriminator r as the visitor sent // it. It cannot send these as they came: // - a target that does not start with /, or that starts with //; -// - an empty Host, for which it sends servewaf's own address; +// - an empty Host, for which it sends discriminator's own address; // - a Host holding an IPv6 zone, which it removes; // - more than one User-Agent, of which it sends the first; // - an empty User-Agent, which it leaves out, the one case that still goes to -// servewaf. +// discriminator. func canBeAsked(r *http.Request) bool { target, host := r.RequestURI, r.Host hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%") @@ -169,13 +170,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) { question.TransferEncoding = in.TransferEncoding question.Header = in.Header.Clone() - question.Header.Set(servewaf.ClientHeader, rq.client.String()) - question.Header.Set(servewaf.PeerHeader, rq.peer.String()) - question.Header.Del(servewaf.ContentLengthHeader) + question.Header.Set(discriminator.ClientHeader, rq.client.String()) + question.Header.Set(discriminator.PeerHeader, rq.peer.String()) + question.Header.Del(discriminator.ContentLengthHeader) announced := in.Header.Get("Content-Length") if announced != "" { - question.Header.Set(servewaf.ContentLengthHeader, announced) + question.Header.Set(discriminator.ContentLengthHeader, announced) } question.Header.Set(requestIDHeader, rq.id) @@ -190,13 +191,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) { return question, nil } -// answerWith gives the visitor servewaf's answer, less its disposition. -func (rq *request) answerWith(answer *servewafAnswer) { +// answerWith gives the visitor discriminator's answer, less its disposition. +func (rq *request) answerWith(answer *discriminatorAnswer) { rq.prepareOwnAnswer(rq.requestDeadline()) header := rq.out.Header() maps.Copy(header, answer.header) - header.Del(servewaf.DispositionHeader) + header.Del(discriminator.DispositionHeader) // Go's server adds a Content-Type it guesses from the body to a response whose // headers have no Content-Type key. The key with no value sends none. @@ -207,10 +208,10 @@ func (rq *request) answerWith(answer *servewafAnswer) { rq.send(answer.status, answer.body) } -// sendReport sends servewaf report on a goroutine of its own, so that the +// sendReport sends discriminator report on a goroutine of its own, so that the // visitor does not wait for it. The report goes on after ctx, the request's own // context, has ended. -func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) { +func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) { h.reportsMu.Lock() defer h.reportsMu.Unlock() @@ -226,9 +227,9 @@ func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) { }) } -// postReport posts report to servewaf, and waits for its answer for up to +// postReport posts report to discriminator, and waits for its answer for up to // SWRP_TIMEOUT_MS. -func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error { +func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error { body, err := json.Marshal(report) if err != nil { return fmt.Errorf("encode the report: %w", err) @@ -245,7 +246,7 @@ func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error req.Header.Set("Content-Type", "application/json") - res, err := h.serveWAFTransport.RoundTrip(req) + res, err := h.discriminatorTransport.RoundTrip(req) if err != nil { return fmt.Errorf("send the report: %w", err) } diff --git a/internal/rproxy/limits_test.go b/internal/rproxy/limits_test.go index 0c5073b..57224e7 100644 --- a/internal/rproxy/limits_test.go +++ b/internal/rproxy/limits_test.go @@ -16,9 +16,9 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rproxy" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // post is the start of a POST whose headers end with what follows it. @@ -58,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) { } } -func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) { +func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) @@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) { func TestADenyOutranksTheBodySizeLimit(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ maxRequestSize: "1K", @@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) { got := visit(t, newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(make([]byte, 1025)))) if got.status != http.StatusForbidden { - t.Errorf("answered %+v, want servewaf's 403", got) + t.Errorf("answered %+v, want discriminator's 403", got) } } @@ -132,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) { var asked atomic.Bool - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { asked.Store(true) greet(w, r) @@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) { t.Run("announced, is refused", func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(contentLengthHeader, strconv.Itoa(len(large))) _, _ = io.WriteString(w, large) @@ -187,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) { t.Run("not announced, is cut off", func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, large[:1]) _ = http.NewResponseController(w).Flush() @@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing. const announced = "2048" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(contentLengthHeader, announced) }) @@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) { t.Run(setting, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ setting: "off", @@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) { wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))) - if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK { + if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK { t.Errorf("answered %+v, want rproxy's own 200", got) } }) @@ -270,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ maxRequestTime: "500", @@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) { } if len(standIn.questions) != 0 { - t.Error("servewaf was asked") + t.Error("discriminator was asked") } standIn.wantNoReport(t) @@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) { func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) @@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { } if len(standIn.questions) != 0 { - t.Error("servewaf was asked") + t.Error("discriminator was asked") } } @@ -334,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) @@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) { } if len(standIn.questions) != 0 { - t.Error("servewaf was asked") + t.Error("discriminator was asked") } }) } @@ -404,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) { announced = 100 * questionBodyMaxBytes ) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) @@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) { name, target string status int }{ - {"servewaf's deny", "/", http.StatusForbidden}, + {"discriminator's deny", "/", http.StatusForbidden}, {"rproxy's own 400", "x:admin", http.StatusBadRequest}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{ maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), @@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) { func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ maxRequestTime: "3000", @@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) { writeSize = 32 << 10 ) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t), map[string]string{maxRequestTime: "3000"}) @@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) { t.Parallel() release := make(chan struct{}) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(http.ResponseWriter, *http.Request) { <-release }) @@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) { const start = "the start" release := make(chan struct{}) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, start) _ = http.NewResponseController(w).Flush() @@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) { writeSize = 32 << 10 ) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { part := make([]byte, writeSize) @@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) { func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) addr, _ := startRProxy(t, standIn.URL, nowhere, nil) got := get(t, addr, "/") @@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, greeting) }) @@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) { t.Parallel() working := make(chan struct{}) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { close(working) <-r.Context().Done() @@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing const answer = "the end of a long answer" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) _ = http.NewResponseController(w).Flush() @@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing {"a deny", post, http.StatusForbidden}, { "the health check", - "GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n", + "GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n", http.StatusOK, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) // The time limit is long enough for a loaded host to read the first part of // the body before it runs out. diff --git a/internal/rproxy/question_test.go b/internal/rproxy/question_test.go index d1f7784..5940ab9 100644 --- a/internal/rproxy/question_test.go +++ b/internal/rproxy/question_test.go @@ -12,11 +12,11 @@ import ( "strings" "testing" - "sneak.berlin/go/smallwebwaf/internal/servewaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" ) // questionBodyMaxBytes is the most of a body a question carries. -const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes +const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes // chunked is the encoding of a body whose length the visitor did not announce. const chunked = "chunked" @@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe" func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) @@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { req.Header.Set(forwardedForHeader, "198.51.100.1") req.Header.Set(forwardedProtoHeader, "gopher") req.Header.Set(requestIDHeader, "the-visitor's") - req.Header.Set(servewaf.ClientHeader, "198.51.100.2") - req.Header.Set(servewaf.PeerHeader, "198.51.100.3") - req.Header.Set(servewaf.ContentLengthHeader, "12345") + req.Header.Set(discriminator.ClientHeader, "198.51.100.2") + req.Header.Set(discriminator.PeerHeader, "198.51.100.3") + req.Header.Set(discriminator.ContentLengthHeader, "12345") // Go's client then sends no User-Agent, so that one in the question would be // Go's own. req.Header["User-Agent"] = nil @@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { want.Set(forwardedForHeader, "198.51.100.1") want.Set(forwardedProtoHeader, "http") want.Set(requestIDHeader, asked.header.Get(requestIDHeader)) - want.Set(servewaf.ClientHeader, localhost) - want.Set(servewaf.PeerHeader, localhost) + want.Set(discriminator.ClientHeader, localhost) + want.Set(discriminator.PeerHeader, localhost) if !maps.EqualFunc(asked.header, want, slices.Equal) { t.Errorf("the question's headers are %v, want %v", asked.header, want) @@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) forwarded := make(chan http.Header, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { forwarded <- r.Header.Clone() @@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { wantGreeting(t, visit(t, req)) asked := standIn.question(t) - if asked.header.Get(servewaf.ClientHeader) != tc.client || - asked.header.Get(servewaf.PeerHeader) != localhost || + if asked.header.Get(discriminator.ClientHeader) != tc.client || + asked.header.Get(discriminator.PeerHeader) != localhost || asked.header.Get(forwardedForHeader) != visitor || asked.header.Get(forwardedProtoHeader) != tc.proto { t.Errorf("the question's headers are %v", asked.header) @@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ trustedProxies: localhost + "/32,10.0.0.0/8", @@ -167,7 +167,8 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) { req.Header[forwardedForHeader] = tc.header wantGreeting(t, visit(t, req)) - if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client { + if got := standIn.question(t).header.Get(discriminator.ClientHeader); got != + tc.client { t.Errorf("the question names %s, want %s", got, tc.client) } }) @@ -182,7 +183,7 @@ func randomBytes(n int) []byte { return body } -func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) { +func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) { t.Parallel() for _, size := range []int{ @@ -197,7 +198,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) { t.Parallel() body := randomBytes(size) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) received := make(chan []byte, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { whole, _ := io.ReadAll(r.Body) @@ -243,7 +244,7 @@ func wantQuestionBody( len(want)) } - values := asked.header.Values(servewaf.ContentLengthHeader) + values := asked.header.Values(discriminator.ContentLengthHeader) if announced { if !slices.Equal(values, []string{strconv.Itoa(size)}) || @@ -264,7 +265,7 @@ func wantQuestionBody( func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) @@ -275,7 +276,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { strings.Repeat("x", questionBodyMaxBytes)) if status := readStatus(t, conn); status != http.StatusForbidden { - t.Errorf("status %d, want servewaf's 403", status) + t.Errorf("status %d, want discriminator's 403", status) } if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes { @@ -306,14 +307,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) { t.Run(method+" "+tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) addr, _ := startRProxy(t, standIn.URL, nowhere, nil) conn := dial(t, addr) send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) if status := readStatus(t, conn); status != http.StatusForbidden { - t.Errorf("status %d, want servewaf's 403", status) + t.Errorf("status %d, want discriminator's 403", status) } asked := standIn.question(t) @@ -334,7 +335,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { t.Run(method, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) @@ -353,7 +354,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { } if len(standIn.questions) != 0 { - t.Error("servewaf was asked") + t.Error("discriminator was asked") } standIn.wantNoReport(t) @@ -361,7 +362,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { } } -func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { +func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) { t.Parallel() for _, method := range []string{ @@ -372,7 +373,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { t.Parallel() reached := make(chan string, 1) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { reached <- r.Method }) @@ -384,7 +385,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { } if asked := standIn.question(t); asked.method != method { - t.Errorf("servewaf was asked about %s", asked.method) + t.Errorf("discriminator was asked about %s", asked.method) } if atApp := <-reached; atApp != method { @@ -394,7 +395,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { } } -func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { +func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) { t.Parallel() const host = "Host: site.example\r\n\r\n" @@ -416,7 +417,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) @@ -434,7 +435,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { } if len(standIn.questions) != 0 { - t.Error("servewaf was asked") + t.Error("discriminator was asked") } standIn.wantNoReport(t) @@ -442,12 +443,12 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { } } -func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) { +func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) { t.Parallel() const host = "a%b" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) @@ -468,7 +469,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) { const want = "http" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ trustedProxies: localhost + "/32", @@ -497,7 +498,7 @@ func TestAQuestionNamesNoZone(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) _, out, server := startServer(t, standIn.URL, app.URL, map[string]string{ trustedProxies: localhost + "/32", @@ -510,8 +511,8 @@ func TestAQuestionNamesNoZone(t *testing.T) { server.Handler.ServeHTTP(httptest.NewRecorder(), req) asked := standIn.question(t) - if asked.header.Get(servewaf.ClientHeader) != tc.client || - asked.header.Get(servewaf.PeerHeader) != tc.peer { + if asked.header.Get(discriminator.ClientHeader) != tc.client || + asked.header.Get(discriminator.PeerHeader) != tc.peer { t.Errorf("the question's headers are %v", asked.header) } diff --git a/internal/rproxy/request.go b/internal/rproxy/request.go index cd30dc5..d60cf47 100644 --- a/internal/rproxy/request.go +++ b/internal/rproxy/request.go @@ -16,16 +16,16 @@ import ( "sync/atomic" "time" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rules" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) // flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as // soon as it arrives. const flushAfterEachWrite time.Duration = -1 -// reportStatusMax is the highest status servewaf takes in a report. An app can +// reportStatusMax is the highest status discriminator takes in a report. An app can // send a higher one, up to 999, and the visitor gets that as it is. const reportStatusMax = 599 @@ -49,14 +49,14 @@ type request struct { peerTrusted bool start time.Time - // disposition is what is done with the request: servewaf's valid disposition, + // disposition is what is done with the request: discriminator's valid disposition, // or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one. disposition string - // answered is true when servewaf gave disposition, which makes a report due, + // answered is true when discriminator gave disposition, which makes a report due, // and byDefault when SWRP_DEFAULT_DISPOSITION did. answered bool byDefault bool - // asn and country are servewaf's X-Client-ASN and X-Client-Country, for the + // asn and country are discriminator's X-Client-ASN and X-Client-Country, for the // app. asn, country string @@ -122,14 +122,14 @@ func (rq *request) answerHealth() { // 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 -// read, so that servewaf is asked first; passToApp then refuses it. +// read, so that discriminator is asked first; passToApp then refuses it. func (rq *request) readFirstPartOfBody() bool { if rq.body == nil { return true } _ = rq.rc.SetReadDeadline(rq.requestDeadline()) - held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes)) + held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes)) // From here on, the request timer ends a read of the rest of the body, so that // the answer can name the side rproxy was waiting on. _ = rq.rc.SetReadDeadline(time.Time{}) @@ -192,7 +192,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, // without any X-Client-ASN or X-Client-Country the visitor sent, and with -// servewaf's. +// discriminator's. func (rq *request) rewrite(pr *httputil.ProxyRequest) { upstream := rq.h.config.UpstreamURL pr.Out.URL.Scheme = upstream.Scheme @@ -312,7 +312,7 @@ func (rq *request) answer(status int) { // handler has returned, after finish has written the log line and sent the report. // Without Content-Length, the flush would send the body in chunks, the last of // which would still wait for the handler. A HEAD's answer has no body, so any -// Content-Length it has stays as servewaf gave it. +// Content-Length it has stays as discriminator gave it. func (rq *request) send(status int, body []byte) { if rq.in.Method != http.MethodHead { rq.out.Header().Set("Content-Length", strconv.Itoa(len(body))) @@ -347,7 +347,7 @@ func (rq *request) refuse(status int) { } } -// finish ends the request's time limits, writes its log line and, if servewaf +// finish ends the request's time limits, writes its log line and, if discriminator // answered the question, sends the report. ctx is the request's own context. func (rq *request) finish(ctx context.Context) { rq.stopTimers() @@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) { return } - report := &servewaf.Report{ + report := &discriminator.Report{ RequestID: rq.id, Status: min(rq.out.status, reportStatusMax), RequestBytes: requestBytes, diff --git a/internal/rproxy/rproxy.go b/internal/rproxy/rproxy.go index eb3a225..c90b1fe 100644 --- a/internal/rproxy/rproxy.go +++ b/internal/rproxy/rproxy.go @@ -1,10 +1,10 @@ -// Package rproxy is the reverse proxy in front of one app that asks servewaf -// about each request. It passes a request servewaf allows to the app and the +// 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 -// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION -// decides only when servewaf cannot be connected to, does not answer at all, or +// discriminator goes to the visitor as discriminator gave it. SWRP_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 -// method it does not pass on, or one it cannot put to servewaf as the visitor +// 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 // SPEC.md says what it does. package rproxy @@ -22,10 +22,10 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" - "sneak.berlin/go/smallwebwaf/internal/servewaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" ) -// How rproxy keeps connections to the app and to servewaf open between +// How rproxy keeps connections to the app and to discriminator open between // requests. const ( idleConns = 100 @@ -45,9 +45,9 @@ const ( idleTimeout = 120 * time.Second ) -// adminPrefix starts the path of every request for smallwebwaf itself, which +// adminPrefix starts the path of every request for sww itself, which // never reaches the app. -const adminPrefix = "/_smallwebwaf/" +const adminPrefix = "/_sww/" // allowedMethods lists the methods rproxy passes on, as an Allow header gives // them. It answers any other method 405 itself. @@ -62,14 +62,14 @@ type Params struct { ProcessLog *slog.Logger } -// Server is the server `smallwebwaf rproxy` runs. +// Server is the server `sww rproxy` runs. type Server struct { *http.Server h *handler } -// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses +// 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 // the headers arrive. The handler applies the time and size limits from then @@ -78,14 +78,14 @@ func New(params Params) *Server { backend := params.Config.WAFBackend errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) h := &handler{ - config: params.Config, - requestLog: params.RequestLog, - processLog: params.ProcessLog, - errorLog: errorLog, - transport: newTransport(), - serveWAFTransport: newServeWAFTransport(), + config: params.Config, + requestLog: params.RequestLog, + processLog: params.ProcessLog, + errorLog: errorLog, + transport: newTransport(), + discriminatorTransport: newDiscriminatorTransport(), reportURL: (&url.URL{ - Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath, + Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath, }).String(), } @@ -120,10 +120,10 @@ type handler struct { requestLog io.Writer processLog *slog.Logger errorLog *log.Logger - // transport carries requests to the app, and serveWAFTransport questions and - // reports to servewaf. - transport http.RoundTripper - serveWAFTransport http.RoundTripper + // transport carries requests to the app, and discriminatorTransport questions and + // reports to discriminator. + transport http.RoundTripper + discriminatorTransport http.RoundTripper // reportURL is where the reports go. reportURL string // reports waits for the reports being sent. reportsMu guards starting one @@ -145,11 +145,12 @@ func newTransport() *http.Transport { } } -// newServeWAFTransport returns what carries questions and reports to servewaf. +// newDiscriminatorTransport returns what carries questions and reports to +// discriminator. // It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade // header, or a Connection header other than close or keep-alive, and that would -// read as servewaf giving no answer. -func newServeWAFTransport() *http.Transport { +// read as discriminator giving no answer. +func newDiscriminatorTransport() *http.Transport { transport := newTransport() transport.Protocols = new(http.Protocols) transport.Protocols.SetHTTP1(true) @@ -158,15 +159,15 @@ func newServeWAFTransport() *http.Transport { } // ServeHTTP handles one request: it answers the health check itself, refuses a -// request with a method it does not pass on or one it cannot put to servewaf as +// request with a method it does not pass on or one it cannot put to discriminator as // the visitor sent it, and for any other request reads the first part of the -// body, asks servewaf, and does what the answer says. Once the answer has ended, +// body, asks discriminator, and does what the answer says. Once the answer has ended, // it writes the request's log line and sends its report. func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq := h.newRequest(w, r) defer rq.finish(r.Context()) - if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath { + if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath { rq.answerHealth() return @@ -199,9 +200,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq.noteAnswer(answer) switch rq.disposition { - case servewaf.DispositionAllow: + case discriminator.DispositionAllow: rq.passToApp(r.Context()) - case servewaf.DispositionClose: + case discriminator.DispositionClose: // This panic has Go's server close the connection without an answer, // and log nothing; the deferred finish still runs. panic(http.ErrAbortHandler) @@ -211,7 +212,7 @@ 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. servewaf answers a path under adminPrefix itself, so such a +// it from the app. discriminator answers a path under adminPrefix itself, so such a // request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the // request's own context. func (rq *request) passToApp(ctx context.Context) { diff --git a/internal/rproxy/rproxy_test.go b/internal/rproxy/rproxy_test.go index b48fb0e..92bd8ed 100644 --- a/internal/rproxy/rproxy_test.go +++ b/internal/rproxy/rproxy_test.go @@ -19,9 +19,9 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rproxy" - "sneak.berlin/go/smallwebwaf/internal/servewaf" ) const ( @@ -54,27 +54,27 @@ const ( // The headers the tests set and look at. const ( - dispositionHeader = servewaf.DispositionHeader + dispositionHeader = discriminator.DispositionHeader forwardedForHeader = "X-Forwarded-For" forwardedProtoHeader = "X-Forwarded-Proto" requestIDHeader = "X-Request-ID" asnHeader = "X-Client-Asn" countryHeader = "X-Client-Country" contentLengthHeader = "Content-Length" - // fromServeWAFHeader is a header the stand-in for servewaf adds to a deny. - fromServeWAFHeader = "X-From-Servewaf" + // fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny. + fromDiscriminatorHeader = "X-From-Discriminator" // addedHeaderValue is the value of a header a test adds to an answer, to see it // reach the visitor. addedHeaderValue = "yes" ) -// What the stand-in for servewaf answers. +// What the stand-in for discriminator answers. const ( - dispositionAllow = servewaf.DispositionAllow - dispositionDeny = servewaf.DispositionDeny - dispositionClose = servewaf.DispositionClose + dispositionAllow = discriminator.DispositionAllow + dispositionDeny = discriminator.DispositionDeny + dispositionClose = discriminator.DispositionClose // deniedBody is the body of a deny. - deniedBody = "refused by servewaf" + deniedBody = "refused by discriminator" ) // output collects what rproxy writes on stdout. @@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) { return len(p), nil } -// question is what the stand-in for servewaf received as a question. +// question is what the stand-in for discriminator received as a question. type question struct { method, target, host string header http.Header @@ -167,26 +167,26 @@ type question struct { transferEncoding []string } -// serveWAF is a stand-in for servewaf that answers each question as answer -// does, and keeps the questions and the reports it gets. -type serveWAF struct { +// Discriminator is a stand-in for discriminator that answers each question as +// answer does, and keeps the questions and the reports it gets. +type Discriminator struct { *httptest.Server questions chan question - reports chan servewaf.Report + reports chan discriminator.Report } -// startServeWAF starts a stand-in for servewaf. -func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF { +// startDiscriminator starts a stand-in for discriminator. +func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator { t.Helper() - s := &serveWAF{ + s := &Discriminator{ questions: make(chan question, 10), - reports: make(chan servewaf.Report, 10), + reports: make(chan discriminator.Report, 10), } s.Server = httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(servewaf.ClientHeader) == "" { + if r.Header.Get(discriminator.ClientHeader) == "" { s.takeReport(t, w, r) return @@ -209,14 +209,17 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF { return s } -// takeReport keeps a report and answers it as servewaf does. -func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) { +// takeReport keeps a report and answers it as discriminator does. +func (s *Discriminator) takeReport( + t *testing.T, w http.ResponseWriter, r *http.Request, +) { t.Helper() - var report servewaf.Report + var report discriminator.Report err := json.NewDecoder(r.Body).Decode(&report) - if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath { + if err != nil || r.Method != http.MethodPost || + r.URL.Path != discriminator.ReportPath { t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err) } @@ -226,7 +229,7 @@ func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Reque } // wantNoReport checks that no report comes for a moment. -func (s *serveWAF) wantNoReport(t *testing.T) { +func (s *Discriminator) wantNoReport(t *testing.T) { t.Helper() select { @@ -237,7 +240,7 @@ func (s *serveWAF) wantNoReport(t *testing.T) { } // report waits for a report. -func (s *serveWAF) report(t *testing.T) servewaf.Report { +func (s *Discriminator) report(t *testing.T) discriminator.Report { t.Helper() select { @@ -246,12 +249,12 @@ func (s *serveWAF) report(t *testing.T) servewaf.Report { case <-time.After(waitLimit): t.Fatal("no report") - return servewaf.Report{} + return discriminator.Report{} } } // question waits for a question. -func (s *serveWAF) question(t *testing.T) question { +func (s *Discriminator) question(t *testing.T) question { t.Helper() select { @@ -264,16 +267,17 @@ func (s *serveWAF) question(t *testing.T) question { } } -// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each +// startDiscriminatorAnsweringReports starts a stand-in for discriminator that +// answers each // question as answer does and each report as report does, and returns its URL. -func startServeWAFAnsweringReports( +func startDiscriminatorAnsweringReports( t *testing.T, answer, report http.HandlerFunc, ) string { t.Helper() backend := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(servewaf.ClientHeader) != "" { + if r.Header.Get(discriminator.ClientHeader) != "" { answer(w, r) return @@ -295,7 +299,7 @@ func allow(w http.ResponseWriter, _ *http.Request) { // deny answers a question with deny, status 403 and a body of its own. func deny(w http.ResponseWriter, _ *http.Request) { w.Header().Set(dispositionHeader, dispositionDeny) - w.Header().Set(fromServeWAFHeader, addedHeaderValue) + w.Header().Set(fromDiscriminatorHeader, addedHeaderValue) w.WriteHeader(http.StatusForbidden) _, _ = io.WriteString(w, deniedBody) } @@ -316,7 +320,7 @@ func greet(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, greeting) } -// startRProxy starts rproxy with servewaf at backend and the app at appURL, and +// startRProxy starts rproxy with discriminator at backend and the app at appURL, and // the settings in env on top of the defaults. It returns where rproxy listens // and what it writes. func startRProxy( @@ -498,7 +502,7 @@ func readStatus(t *testing.T, conn net.Conn) int { func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set(asnHeader, "AS64496") w.Header().Set(countryHeader, "DE") allow(w, r) @@ -557,7 +561,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) { "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", } - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) seen := make(chan http.Header, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { seen <- r.Header.Clone() @@ -602,7 +606,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) { func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) seen := make(chan http.Header, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { seen <- r.Header.Clone() @@ -641,7 +645,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { w.Header()["Content-Type"] = nil _, _ = io.WriteString(w, "") @@ -663,19 +667,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { } } -func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) { +func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) { t.Parallel() for _, tc := range []struct { name string backend func(t *testing.T) string }{ - {"servewaf allows", func(t *testing.T) string { + {"discriminator allows", func(t *testing.T) string { t.Helper() - return startServeWAF(t, allow).URL + return startDiscriminator(t, allow).URL }}, - {"servewaf is down", func(*testing.T) string { return nowhere }}, + {"discriminator is down", func(*testing.T) string { return nowhere }}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -701,10 +705,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) { } } -func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { +func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) @@ -712,8 +716,8 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { got := get(t, addr, "/") if got.status != http.StatusForbidden || got.body != deniedBody || - got.header.Get(fromServeWAFHeader) != addedHeaderValue { - t.Errorf("answered %+v, want servewaf's answer", got) + got.header.Get(fromDiscriminatorHeader) != addedHeaderValue { + t.Errorf("answered %+v, want discriminator's answer", got) } if len(got.header.Values(dispositionHeader)) != 0 { @@ -729,21 +733,21 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { line := out.requestLine(t) if line["disposition"] != dispositionDeny || line["default_disposition"] != nil { - t.Errorf("line %v, want servewaf's deny", line) + t.Errorf("line %v, want discriminator's deny", line) } } -func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) { +func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) if got.status != http.StatusForbidden || got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) { - t.Errorf("answered %+v, want servewaf's 403 and its length", got) + t.Errorf("answered %+v, want discriminator's 403 and its length", got) } } @@ -758,13 +762,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) { }{ {"a deny", "GET /" + host, deniedBody, http.StatusForbidden}, {"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest}, - {"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK}, + {"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() log := heldLog{release: make(chan struct{})} - standIn := startServeWAF(t, deny) + standIn := startDiscriminator(t, deny) app := startApp(t, greet) addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard) @@ -794,7 +798,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) { func TestRProxysOwnAnswerIsPlainText(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, nil) @@ -817,7 +821,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) { func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(dispositionHeader, dispositionClose) w.WriteHeader(http.StatusOK) }) @@ -849,7 +853,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { t.Parallel() - backend := startServeWAFAnsweringReports(t, + backend := startDiscriminatorAnsweringReports(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(dispositionHeader, dispositionClose) }, @@ -874,17 +878,17 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { } } -// answeringWith returns what starts a stand-in for servewaf that answers each +// answeringWith returns what starts a stand-in for discriminator that answers each // question as answer does, and returns its URL. func answeringWith(answer http.HandlerFunc) func(t *testing.T) string { return func(t *testing.T) string { t.Helper() - return startServeWAF(t, answer).URL + return startDiscriminator(t, answer).URL } } -// noAnswers are the ways servewaf can leave the decision to the default. +// noAnswers are the ways discriminator can leave the decision to the default. func noAnswers() map[string]func(t *testing.T) string { return map[string]func(t *testing.T) string{ "no connection": func(*testing.T) string { return nowhere }, @@ -916,7 +920,7 @@ func noAnswers() map[string]func(t *testing.T) string { } } -func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { +func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -925,7 +929,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { }{ {"a refusal", dispositionDeny, deniedBody, http.StatusForbidden}, {"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests}, - {"servewaf's 400", "", "refused\n", http.StatusBadRequest}, + {"discriminator's 400", "", "refused\n", http.StatusBadRequest}, {"a 500", "", "broken\n", http.StatusInternalServerError}, {"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable}, {"no header", "", "who knows\n", http.StatusOK}, @@ -935,12 +939,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { t.Run(tc.name+" with the default "+setting, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) { if tc.disposition != "" { w.Header().Set(dispositionHeader, tc.disposition) } - w.Header().Set(fromServeWAFHeader, addedHeaderValue) + w.Header().Set(fromDiscriminatorHeader, addedHeaderValue) w.WriteHeader(tc.status) _, _ = io.WriteString(w, tc.body) }) @@ -953,8 +957,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { got := get(t, addr, "/") if got.status != tc.status || got.body != tc.body || - got.header.Get(fromServeWAFHeader) != addedHeaderValue { - t.Errorf("answered %+v, want servewaf's answer", got) + got.header.Get(fromDiscriminatorHeader) != addedHeaderValue { + t.Errorf("answered %+v, want discriminator's answer", got) } if len(got.header.Values(dispositionHeader)) != 0 { @@ -966,8 +970,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { t.Errorf("line %v, want status %d and no default", line, tc.status) } - if out.has("servewaf gave no answer") { - t.Error("rproxy warned that servewaf gave no answer") + if out.has("discriminator gave no answer") { + t.Error("rproxy warned that discriminator gave no answer") } }) } @@ -977,14 +981,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "refused", http.StatusBadRequest) }) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) if got := get(t, addr, "/"); got.status != http.StatusBadRequest { - t.Errorf("answered %+v, want servewaf's 400", got) + t.Errorf("answered %+v, want discriminator's 400", got) } if line := out.requestLine(t); line["disposition"] != nil { @@ -997,7 +1001,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) { func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) { w.Header()["Content-Type"] = nil w.WriteHeader(http.StatusInternalServerError) _, _ = io.WriteString(w, "broken") @@ -1015,7 +1019,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) { const size = 1 << 20 - standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write(make([]byte, size)) }) @@ -1023,15 +1027,16 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) { got := get(t, addr, "/") if got.status != http.StatusInternalServerError || len(got.body) != size { - t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d", + t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d", got.status, len(got.body), size) } } -// startServeWAFWriting starts a stand-in for servewaf that reads each question and +// startDiscriminatorWriting starts a stand-in for discriminator that reads +// each question and // answers it with text, as it is, and returns its URL. It can send what Go's // server would not. -func startServeWAFWriting(t *testing.T, text string) string { +func startDiscriminatorWriting(t *testing.T, text string) string { t.Helper() listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") @@ -1101,7 +1106,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { "a status below 100": func(t *testing.T) string { t.Helper() - return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n") + return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n") }, "a 101 that holds its connection open": answeringWith(switchAndHold), } { @@ -1126,7 +1131,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { t.Errorf("line %v, want status 502 and no disposition", line) } - out.waitFor(t, "servewaf's answer is dropped") + out.waitFor(t, "discriminator's answer is dropped") }) } } @@ -1135,7 +1140,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, overOneMiB(dispositionDeny)) + standIn := startDiscriminator(t, overOneMiB(dispositionDeny)) addr, out := startRProxy(t, standIn.URL, nowhere, nil) if got := get(t, addr, "/"); got.status != http.StatusBadGateway { @@ -1192,14 +1197,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { } } -func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) { +func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) { t.Parallel() for _, disposition := range []string{dispositionAllow, dispositionDeny} { t.Run("with the default "+disposition, func(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { + standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) app := startApp(t, func(http.ResponseWriter, *http.Request) { @@ -1222,8 +1227,8 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) { t.Errorf("line %v, want status 0 and no disposition", line) } - if out.has("servewaf gave no answer") { - t.Error("rproxy warned that servewaf gave no answer") + if out.has("discriminator gave no answer") { + t.Error("rproxy warned that discriminator gave no answer") } standIn.wantNoReport(t) @@ -1238,13 +1243,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) { addr, out := startRProxy(t, nowhere, app.URL, nil) wantGreeting(t, get(t, addr, "/")) - out.waitFor(t, "servewaf gave no answer") + out.waitFor(t, "discriminator gave no answer") } func TestSendsNoReportWithoutAnAnswer(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { + standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) app := startApp(t, greet) @@ -1265,35 +1270,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) { }) addr, _ := startRProxy(t, nowhere, app.URL, nil) - got := get(t, addr, "/_smallwebwaf/metrics") + got := get(t, addr, "/_sww/metrics") if got.status != http.StatusNotFound { t.Errorf("answered %+v, want 404", got) } } -func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) { +func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, nowhere, app.URL, nil) - got := get(t, addr, servewaf.HealthPath) + got := get(t, addr, discriminator.HealthPath) if got.status != http.StatusOK || got.body != "ok\n" { t.Errorf("answered %+v, want ok", got) } line := out.requestLine(t) - if line["path"] != servewaf.HealthPath || line["disposition"] != nil { + if line["path"] != discriminator.HealthPath || line["disposition"] != nil { t.Errorf("line %v, want one with no disposition", line) } - // With servewaf up too, it is not asked. + // With discriminator up too, it is not asked. addr, _ = startRProxy(t, standIn.URL, app.URL, nil) - get(t, addr, servewaf.HealthPath) + get(t, addr, discriminator.HealthPath) if len(standIn.questions) != 0 { - t.Error("servewaf was asked about the health check") + t.Error("discriminator was asked about the health check") } } @@ -1301,7 +1306,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) { t.Parallel() release := make(chan struct{}) - backend := startServeWAFAnsweringReports(t, deny, + backend := startDiscriminatorAnsweringReports(t, deny, func(w http.ResponseWriter, r *http.Request) { select { case <-release: @@ -1336,16 +1341,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) { _ = res.Body.Close() if res.StatusCode != http.StatusForbidden { - t.Errorf("status %d, want servewaf's 403", res.StatusCode) + t.Errorf("status %d, want discriminator's 403", res.StatusCode) } } } -func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) { +func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) { t.Parallel() release := make(chan struct{}) - backend := startServeWAFAnsweringReports(t, allow, + backend := startDiscriminatorAnsweringReports(t, allow, func(w http.ResponseWriter, _ *http.Request) { <-release @@ -1365,10 +1370,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) { out.waitFor(t, "a report is dropped") } -func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) { +func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) { t.Parallel() - backend := startServeWAFAnsweringReports(t, allow, + backend := startDiscriminatorAnsweringReports(t, allow, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) }) @@ -1383,7 +1388,7 @@ func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) { func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) { t.Parallel() - backend := startServeWAFAnsweringReports(t, allow, switchAndHold) + backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold) app := startApp(t, greet) addr, out := startRProxy(t, backend, app.URL, map[string]string{ timeoutSetting: "60000", @@ -1396,7 +1401,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) { func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out, server := startServer(t, standIn.URL, app.URL, nil) @@ -1412,7 +1417,7 @@ func TestReportGivesWhatHappened(t *testing.T) { const body = "the visitor's body" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) @@ -1442,7 +1447,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) { release := make(chan struct{}) backend := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(servewaf.ClientHeader) != "" { + if r.Header.Get(discriminator.ClientHeader) != "" { allow(w, r) return @@ -1497,7 +1502,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) { func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { t.Parallel() - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, echoAfterUpgrade) addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ maxRequestTime: "2000", @@ -1552,7 +1557,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) { const goodbye = "sent after the visitor stopped sending\n" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { conn, buffered, err := http.NewResponseController(w).Hijack() if err != nil { @@ -1610,7 +1615,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) { //nolint:paralleltest // see above func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) { closed := make(chan error, 1) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { conn, buffered, err := http.NewResponseController(w).Hijack() if err != nil { @@ -1681,7 +1686,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) { const fromTraefik = "an-id-from-traefik" - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ trustedProxies: localhost + "/32", @@ -1711,7 +1716,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) { body = "the visitor's body" ) - standIn := startServeWAF(t, allow) + standIn := startDiscriminator(t, allow) app := startApp(t, greet) addr, out := startRProxy(t, standIn.URL, app.URL, nil) diff --git a/internal/rules/rules.go b/internal/rules/rules.go index ba024ab..3e4014c 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 -// SWWAF_RULES_DIR, one rule to a line, that each request is checked +// SWW_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 SWWAF_RULES_DIR must go without a change before +// quietTime is how long SWW_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,19 +100,19 @@ type Rule struct { // Params are what Load needs. type Params struct { - // Dir is the directory of the rule files (SWWAF_RULES_DIR). + // Dir is the directory of the rule files (SWW_RULES_DIR). Dir string - // Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read + // Enabled is SWW_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 - // rule file edited while smallwebwaf runs. + // rule file edited while sww runs. ProcessLog *slog.Logger // Alerts receive a file_error alert for that error. Alerts *alerts.Queue } -// Files are the rule files of a running smallwebwaf, and the rules read +// Files are the rule files of a running sww, and the rules read // from them. They are safe for concurrent use. type Files struct { params Params @@ -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("SWWAF_RULES_DIR cannot be read: %w", err) + return nil, dir, fmt.Errorf("SWW_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 SWWAF_RULES_DIR + data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR if err != nil { return nil, err } diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go index 43d8956..732da77 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, "SWWAF_RULES_DIR cannot be read: open "+dir+ + wantRefused(t, dir, "SWW_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() - // SWWAF_RULES_DIR does not exist, which would stop the start. + // SWW_RULES_DIR does not exist, which would stop the start. params, _ := newParams(filepath.Join(t.TempDir(), "rules.d")) params.Enabled = false @@ -601,7 +601,7 @@ func wantRefused(t *testing.T, dir, want string) { } // get returns a GET request for target, a path and an optional query, as -// smallwebwaf's server reads it, without a user agent. +// sww's server reads it, without a user agent. func get(t *testing.T, target string) *http.Request { t.Helper() diff --git a/internal/state/state.go b/internal/state/state.go index 296fde1..cfcc1f1 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,5 +1,5 @@ -// Package state keeps smallwebwaf's state in JSON files in -// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires +// Package state keeps sww's state in JSON files in +// SWW_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 @@ -8,7 +8,7 @@ // cooldowns, the hour under way, the alerts waiting for each destination // and the anomaly counters. Load // reads them at start, Watch takes in an admin's edit of one while -// smallwebwaf runs, and Run and WriteAll write them. The disk is read and +// sww runs, and Run and WriteAll write them. The disk is read and // written outside the parts' locks, which are held only to take a // snapshot or to put in what a file holds, so that no request waits on // the disk. @@ -44,7 +44,7 @@ import ( // version is the version of the files' format, the only one read. const version = 1 -// fileMode lets the smallwebwaf user alone read and write the files, which +// fileMode lets the sww user alone read and write the files, which // hold visitors' addresses. const fileMode = 0o600 @@ -72,16 +72,16 @@ var ( // Params are what Load needs. type Params struct { - // Dir is the directory of the state files (SWWAF_STATE_DIR). + // Dir is the directory of the state files (SWW_STATE_DIR). Dir string // WriteDelay is how long after a ban is made bans.json is written - // (SWWAF_STATE_WRITE_DELAY), and CounterInterval how often every file - // is (SWWAF_STATE_COUNTER_INTERVAL). + // (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file + // is (SWW_STATE_COUNTER_INTERVAL). WriteDelay time.Duration CounterInterval time.Duration // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies // hold the state. Alerts also receive a file_error alert for an edit set - // aside, and for a write that fails while smallwebwaf runs. + // aside, and for a write that fails while sww runs. Ledger *bans.Ledger Limiter *ratelimit.Limiter GeoJS *lookup.GeoJS @@ -101,7 +101,7 @@ type Params struct { Metrics *metrics.Metrics } -// Files are the state files of a running smallwebwaf. +// Files are the state files of a running sww. type Files struct { params Params @@ -110,7 +110,7 @@ type Files struct { // it. mu sync.Mutex // sums are the SHA-256 sums of what each file held, by name, when - // smallwebwaf last read or wrote it. A file that holds anything else + // sww last read or wrote it. A file that holds anything else // has been edited since. sums map[string][sha256.Size]byte } @@ -185,7 +185,7 @@ type stateFile interface { func Load(params Params) (*Files, error) { err := checkWritable(params.Dir) if err != nil { - return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err) + return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err) } f := &Files{params: params, sums: map[string][sha256.Size]byte{}} @@ -241,7 +241,7 @@ func (f *Files) Run(ctx context.Context) { } } -// WriteAll writes every state file, as smallwebwaf stops. A file that +// WriteAll writes every state file, as sww stops. A file that // fails does not keep the others from being written. func (f *Files) WriteAll() error { return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON), @@ -250,7 +250,7 @@ func (f *Files) WriteAll() error { // Watch watches Dir until ctx is done, and takes in an admin's edit of a // state file as soon as it is saved: what the file holds replaces what -// smallwebwaf held for it. An edit that does not parse is left for the +// sww held for it. An edit that does not parse is left for the // file's next write, which sets it aside, since a file can be read while // an editor is still writing it. If Dir cannot be watched, that is // logged, and an edit is taken in only before its file is written. @@ -310,7 +310,7 @@ func (f *Files) logFailure(name string, err error) { } // fileChanged takes in what the state file name holds, as Watch sees it -// change, if that is an edit made since smallwebwaf last read or wrote +// change, if that is an edit made since sww last read or wrote // the file. A file that cannot be read or does not parse is left for its // next write. func (f *Files) fileChanged(name string) { @@ -326,7 +326,7 @@ func (f *Files) fileChanged(name string) { } // takeInEdit takes in data, an edit of the state file name, as takeIn -// does, and counts and logs it. Every edit taken in while smallwebwaf +// does, and counts and logs it. Every edit taken in while sww // runs, by Watch or by a write, is taken in here. An edit that does not // parse is neither counted nor logged, and takeIn's error returned. func (f *Files) takeInEdit(name string, data []byte) error { @@ -356,13 +356,13 @@ func (f *Files) read(name string) (int, error) { } // readChanged returns what the state file name holds, and whether that -// has changed since smallwebwaf last read or wrote the file, as it has -// for a file smallwebwaf never read or wrote. A missing file has not +// has changed since sww last read or wrote the file, as it has +// for a file sww never read or wrote. A missing file has not // changed: it is written again at its next write. 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 SWWAF_STATE_DIR + data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } @@ -377,7 +377,7 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) { // takeIn parses data, what the state file name holds, puts it into the // part that keeps that state, in place of what the part held, and returns // how many entries the file holds. edit is whether data is an admin's -// edit taken in while smallwebwaf runs, rather than the file read at the +// edit taken in while sww runs, rather than the file read at the // start. An error names the file and, where the JSON decoder tells it, // the line and column, or else the entry. func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { @@ -491,8 +491,8 @@ func (f *Files) takeInAlerts(path string, data []byte) (int, error) { return entries, nil } -// writeFile writes the state file name from what smallwebwaf holds. An -// edit made since smallwebwaf last read or wrote the file is taken in +// writeFile writes the state file name from what sww holds. An +// edit made since sww last read or wrote the file is taken in // first, so that it is not overwritten, or set aside if it does not // parse. A file that cannot be read, or an edit that cannot be set // aside, is left as it is, and the write given up. Every write is counted @@ -561,7 +561,7 @@ func (f *Files) setAside(name string, parseErr error) error { return nil } -// encode returns the state file name as smallwebwaf writes it, from a +// encode returns the state file name as sww writes it, from a // snapshot of the part that keeps that state. func (f *Files) encode(name string) ([]byte, error) { switch name { @@ -965,7 +965,7 @@ func parse(path string, data []byte, file stateFile) error { err := json.Unmarshal(data, &header) if err == nil && header.Version != version { - err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d", + err = fmt.Errorf("%w %d, where this sww reads version %d", errVersion, header.Version, version) } @@ -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 // SWWAF_STATE_DIR itself + directory, err := os.Open(dir) //nolint:gosec // SWW_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 SWWAF_STATE_DIR + //nolint:gosec // a state file's temporary file, in SWW_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 3a0d769..b62a93e 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -122,7 +122,7 @@ const filledAlertsJSON = `{ { "event": "file_error", "netblock": "", - "file": "/var/lib/smallwebwaf/bans.json", + "file": "/var/lib/sww/bans.json", "sent": "2026-10-06T00:00:00Z", "suppressed_repeats": 0 }, @@ -169,7 +169,7 @@ const filledAlertsJSON = `{ "reason": "writing the state files failed", "detail": { "error": "no space left on device", - "file": "/var/lib/smallwebwaf/bans.json" + "file": "/var/lib/sww/bans.json" }, "suppressed_repeats": 0 } @@ -931,7 +931,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) { filepath.Join(t.TempDir(), "missing"), notADirectory, } { - const want = "SWWAF_STATE_DIR cannot be written: " + const want = "SWW_STATE_DIR cannot be written: " _, err := state.Load(newParams(dir)) if err == nil || !strings.HasPrefix(err.Error(), want) { @@ -1165,13 +1165,13 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) { ) got := scrape(t, params) - wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 1) - wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofClients, 1) - wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 0) - wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans, + wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 1) + wantMetric(t, got, "sww_state_file_writes_total"+ofClients, 1) + wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 0) + wantMetric(t, got, "sww_state_file_size_bytes"+ofBans, float64(len(permanentBansJSON))) - written := metric(t, got, "smallwebwaf_state_file_last_write_timestamp_seconds"+ofBans) + written := metric(t, got, "sww_state_file_last_write_timestamp_seconds"+ofBans) if written < float64(time.Now().Add(-time.Hour).Unix()) { t.Errorf("bans.json was last written at %v, not by that write", written) } @@ -1192,10 +1192,10 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) { } got = scrape(t, params) - wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 2) - wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 1) - wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofClients, 0) - wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans, + wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 2) + wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 1) + wantMetric(t, got, "sww_state_file_write_failures_total"+ofClients, 0) + wantMetric(t, got, "sww_state_file_size_bytes"+ofBans, float64(len(permanentBansJSON))) } @@ -1486,7 +1486,7 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) { "and the reason %q", written, err, reason) } - // Taken in while smallwebwaf runs, a ban on another netblock is an + // Taken in while sww runs, a ban on another netblock is an // admin's too, and one made since the start. watch(t, files, lines) edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24")) @@ -1549,7 +1549,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { watch(t, files, lines) - // While smallwebwaf runs, the broken edit is left as it is: an edit + // While sww runs, the broken edit is left as it is: an edit // of clients.json, made after it and taken in, shows that it has been // seen. edit(t, dir, bansJSON, broken) @@ -1558,7 +1558,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) // The next write sets it aside, logged with where the error is, and - // writes bans.json again from what smallwebwaf still holds. + // writes bans.json again from what sww still holds. err = files.WriteAll() if err != nil { t.Fatalf("write: %v", err) @@ -1613,7 +1613,7 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) { wantTakenIn(t, lines, dir, bansJSON) wantMetric(t, scrape(t, params), - `smallwebwaf_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2) + `sww_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2) } func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) { @@ -1678,7 +1678,7 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) { } wantMetric(t, scrape(t, params), - `smallwebwaf_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1) + `sww_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1) } func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) { @@ -1854,7 +1854,7 @@ func fill(params state.Params) { params.Alerts.Raise(alerts.Alert{ Event: alerts.EventFileError, Reason: "writing the state files failed", Detail: map[string]any{ - "file": "/var/lib/smallwebwaf/bans.json", "error": "no space left on device", + "file": "/var/lib/sww/bans.json", "error": "no space left on device", }, }) params.Alerts.Raise(alerts.Alert{ @@ -2178,7 +2178,7 @@ func scrape(t *testing.T, params state.Params) string { } // metric returns the value of series in text, the metrics, such as -// smallwebwaf_state_file_writes_total{file="bans.json",instance="app"}, or +// sww_state_file_writes_total{file="bans.json",instance="app"}, or // fails the test if there is no such series. func metric(t *testing.T, text, series string) float64 { t.Helper() @@ -2210,8 +2210,8 @@ func wantWriteFailed(t *testing.T, params state.Params, name string) { got := scrape(t, params) file := `{file="` + name + `",instance="app"}` - wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1) - wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1) + wantMetric(t, got, "sww_state_file_writes_total"+file, 1) + wantMetric(t, got, "sww_state_file_write_failures_total"+file, 1) } // wantMetric checks the value of series in text, the metrics, as metric diff --git a/internal/smallwebwaf/servewaf_test.go b/internal/sww/discriminator_test.go similarity index 74% rename from internal/smallwebwaf/servewaf_test.go rename to internal/sww/discriminator_test.go index affcd19..077b7bf 100644 --- a/internal/smallwebwaf/servewaf_test.go +++ b/internal/sww/discriminator_test.go @@ -1,4 +1,4 @@ -package smallwebwaf_test +package sww_test import ( "bytes" @@ -14,14 +14,14 @@ import ( "testing" "time" - "sneak.berlin/go/smallwebwaf/internal/servewaf" - "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" + "sneak.berlin/go/smallwebwaf/internal/sww" ) // commandForMain, while set in the environment, has this test binary run Main // with the command it gives, in place of the tests, so that a test can run // Main in a process of its own. -const commandForMain = "SMALLWEBWAF_TEST_COMMAND" +const commandForMain = "SWW_TEST_COMMAND" func TestMain(m *testing.M) { command, set := os.LookupEnv(commandForMain) @@ -29,12 +29,12 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } - os.Args = strings.Fields("smallwebwaf " + command) + os.Args = strings.Fields("sww " + command) - os.Exit(smallwebwaf.Main(testVersion)) + os.Exit(sww.Main(testVersion)) } -func TestMainRunsServeWAF(t *testing.T) { +func TestMainRunsDiscriminator(t *testing.T) { t.Parallel() binary, err := os.Executable() @@ -50,7 +50,7 @@ func TestMainRunsServeWAF(t *testing.T) { cmd.Stdout = out cmd.Env = append(os.Environ(), - commandForMain+"="+serveWAFCommand, + commandForMain+"="+discriminatorCommand, listenAddr+"="+localhost+":0", stateDir+"="+t.TempDir(), rulesDir+"="+t.TempDir(), @@ -67,7 +67,7 @@ func TestMainRunsServeWAF(t *testing.T) { }) addr, _ := out.line(t, "msg", "starting")["address"].(string) - ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow) + ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow) err = cmd.Process.Signal(syscall.SIGTERM) if err != nil { @@ -108,7 +108,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) { t.Errorf("exit status %d (%v), want 2", code, err) } - for _, name := range []string{"rproxy", serveWAFCommand, "healthcheck"} { + for _, name := range []string{"rproxy", discriminatorCommand, "healthcheck"} { if !strings.Contains(stderr.String(), name) { t.Errorf("stderr %q does not name %s", stderr.String(), name) } @@ -121,7 +121,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) { } } -// ask sends servewaf at addr rproxy's question about a request for path from +// ask sends discriminator at addr rproxy's question about a request for path from // placed, checks the answer's status and disposition, and closes it. func ask(t *testing.T, addr, path string, status int, disposition string) { t.Helper() @@ -132,7 +132,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) { t.Fatalf("new request: %v", err) } - req.Header.Set(servewaf.ClientHeader, placed) + req.Header.Set(discriminator.ClientHeader, placed) req.Header.Set("X-Request-ID", rand.Text()) transport := &http.Transport{} @@ -147,16 +147,16 @@ func ask(t *testing.T, addr, path string, status int, disposition string) { _ = res.Body.Close() if res.StatusCode != status || - res.Header.Get(servewaf.DispositionHeader) != disposition { + res.Header.Get(discriminator.DispositionHeader) != disposition { t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode, - res.Header.Get(servewaf.DispositionHeader), status, disposition) + res.Header.Get(discriminator.DispositionHeader), status, disposition) } } -// runServeWAF runs `smallwebwaf servewaf` with the settings in env, has use +// runDiscriminator runs `sww discriminator` with the settings in env, has use // send it requests at the address it listens on, stops it as SIGTERM does, and // checks that it stopped in order. -func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) { +func runDiscriminator(t *testing.T, env map[string]string, use func(addr string)) { t.Helper() ctx, stop := context.WithCancel(t.Context()) @@ -183,7 +183,7 @@ func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) { out.line(t, "msg", "stopped") } -func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) { +func TestDiscriminatorKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) { t.Parallel() env := map[string]string{ @@ -192,13 +192,13 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) { rulesDir: filepath.Join("..", "..", "share", "rules.d"), } - runServeWAF(t, env, func(addr string) { - ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow) + runDiscriminator(t, env, func(addr string) { + ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow) // The default rule file bans a probe for this. - ask(t, addr, "/.env", http.StatusForbidden, servewaf.DispositionDeny) + ask(t, addr, "/.env", http.StatusForbidden, discriminator.DispositionDeny) }) - runServeWAF(t, env, func(addr string) { - ask(t, addr, "/", http.StatusForbidden, servewaf.DispositionDeny) + runDiscriminator(t, env, func(addr string) { + ask(t, addr, "/", http.StatusForbidden, discriminator.DispositionDeny) }) } diff --git a/internal/smallwebwaf/healthcheck.go b/internal/sww/healthcheck.go similarity index 75% rename from internal/smallwebwaf/healthcheck.go rename to internal/sww/healthcheck.go index a2465c6..e56e8ee 100644 --- a/internal/smallwebwaf/healthcheck.go +++ b/internal/sww/healthcheck.go @@ -1,4 +1,4 @@ -package smallwebwaf +package sww import ( "context" @@ -11,7 +11,7 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" - "sneak.berlin/go/smallwebwaf/internal/servewaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" ) // healthCheckTimeout bounds the whole health check. @@ -19,13 +19,13 @@ const healthCheckTimeout = 5 * time.Second var errHealthEndpoint = errors.New("the health endpoint answered") -// HealthCheck is the container's health check. In servewaf's container, where -// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health -// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container, -// where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy +// 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, +// 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, -// any other value of SMALLWEBWAF_COMMAND included, it writes why to stderr and +// 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 @@ -36,7 +36,7 @@ func HealthCheck( ) int { if len(args) > 0 { _, _ = fmt.Fprintf(stderr, - "smallwebwaf healthcheck: unexpected argument %q\n", args[0]) + "sww healthcheck: unexpected argument %q\n", args[0]) return 1 } @@ -49,8 +49,8 @@ func HealthCheck( switch { case err != nil: err = fmt.Errorf("invalid setting: %w", err) - case command == "servewaf": - err = checkServeWAF(ctx, lookupEnv) + case command == "discriminator": + err = checkDiscriminator(ctx, lookupEnv) default: err = checkRProxyAndApp(ctx, lookupEnv) } @@ -64,16 +64,18 @@ func HealthCheck( return 0 } -// checkServeWAF asks servewaf's health endpoint. -func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error { - listenAddr, err := config.ServeWAFListenAddr(lookupEnv) +// checkDiscriminator asks discriminator's health endpoint. +func checkDiscriminator( + ctx context.Context, lookupEnv func(string) (string, bool), +) error { + listenAddr, err := config.DiscriminatorListenAddr(lookupEnv) if err != nil { return fmt.Errorf("invalid setting: %w", err) } err = askHealthEndpoint(ctx, listenAddr) if err != nil { - return fmt.Errorf("ask servewaf: %w", err) + return fmt.Errorf("ask discriminator: %w", err) } return nil @@ -107,7 +109,7 @@ func checkRProxyAndApp( // listenAddr, which the settings have checked has one. func askHealthEndpoint(ctx context.Context, listenAddr string) error { _, port, _ := net.SplitHostPort(listenAddr) - health := "http://" + net.JoinHostPort("127.0.0.1", port) + servewaf.HealthPath + health := "http://" + net.JoinHostPort("127.0.0.1", port) + discriminator.HealthPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody) if err != nil { diff --git a/internal/smallwebwaf/healthcheck_internal_test.go b/internal/sww/healthcheck_internal_test.go similarity index 96% rename from internal/smallwebwaf/healthcheck_internal_test.go rename to internal/sww/healthcheck_internal_test.go index 7aec7f9..24eb8f0 100644 --- a/internal/smallwebwaf/healthcheck_internal_test.go +++ b/internal/sww/healthcheck_internal_test.go @@ -1,4 +1,4 @@ -package smallwebwaf +package sww import ( "net/url" diff --git a/internal/smallwebwaf/healthcheck_test.go b/internal/sww/healthcheck_test.go similarity index 79% rename from internal/smallwebwaf/healthcheck_test.go rename to internal/sww/healthcheck_test.go index 61f5d21..50a9bf4 100644 --- a/internal/smallwebwaf/healthcheck_test.go +++ b/internal/sww/healthcheck_test.go @@ -1,4 +1,4 @@ -package smallwebwaf_test +package sww_test import ( "bytes" @@ -12,18 +12,18 @@ import ( "testing" "time" - "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" + "sneak.berlin/go/smallwebwaf/internal/sww" ) const ( // commandSetting says which command a container runs, and which the health // check checks. - commandSetting = "SMALLWEBWAF_COMMAND" - // serveWAFCommand is the command that runs servewaf. - serveWAFCommand = "servewaf" + commandSetting = "SWW_COMMAND" + // discriminatorCommand is the command that runs discriminator. + discriminatorCommand = "discriminator" ) -func TestHealthCheckInServeWAFsContainer(t *testing.T) { +func TestHealthCheckInDiscriminatorsContainer(t *testing.T) { t.Parallel() ctx, stop := context.WithCancel(t.Context()) @@ -47,19 +47,19 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) { // The container's settings: an address to listen on with an empty // host part, which the health check asks at 127.0.0.1. wantHealthCheck(t, map[string]string{ - commandSetting: serveWAFCommand, listenAddr: ":" + port, + commandSetting: discriminatorCommand, listenAddr: ":" + port, }, 0, "") // The health check reads that setting alone, here given as a file: a removed // or invalid token file, or an invalid value of another setting, does not // fail it. for _, other := range []struct{ name, value string }{ - {"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, - {"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, - {"SWWAF_MODE", "neither"}, + {"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, + {"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, + {"SWW_MODE", "neither"}, } { wantHealthCheck(t, map[string]string{ - commandSetting: serveWAFCommand, + commandSetting: discriminatorCommand, listenAddr + "_FILE": writeFile(t, ":"+port+"\n"), other.name: other.value, }, 0, "") @@ -68,11 +68,11 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) { // Listening on port 0 takes any free port, so nothing listens on port 0 // itself. wantHealthCheck(t, map[string]string{ - commandSetting: serveWAFCommand, listenAddr: ":0", - }, 1, "unhealthy: ask servewaf: ") + commandSetting: discriminatorCommand, listenAddr: ":0", + }, 1, "unhealthy: ask discriminator: ") wantHealthCheck(t, map[string]string{ - commandSetting: serveWAFCommand, listenAddr: "8080", - }, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ") + commandSetting: discriminatorCommand, listenAddr: "8080", + }, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ") stop() @@ -90,7 +90,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { defer app.Close() // Nothing listens at the backend. The health check asks rproxy and the app, - // whether or not servewaf answers. + // whether or not discriminator answers. site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL) _, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://")) settings := func(more ...string) map[string]string { @@ -106,8 +106,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) { wantHealthCheck(t, settings(commandSetting, ""), 0, "") wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "") - // SMALLWEBWAF_COMMAND cannot be given as a file. - wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")), + // SWW_COMMAND cannot be given as a file. + wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "discriminator\n")), 0, "") // Another setting does not fail it, even an invalid one, and neither does @@ -136,8 +136,8 @@ func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) { t.Parallel() wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1, - "unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+ - "\"healthcheck\" is not rproxy or servewaf\n") + "unhealthy: invalid setting: SWW_COMMAND: "+ + "\"healthcheck\" is not rproxy or discriminator\n") } func TestHealthCheckRefusesAnArgument(t *testing.T) { @@ -149,9 +149,9 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) { return "", false } - got := smallwebwaf.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr) + got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr) - want := "smallwebwaf healthcheck: unexpected argument \"now\"\n" + want := "sww healthcheck: unexpected argument \"now\"\n" if got != 1 || stderr.String() != want { t.Errorf("health check returned %d and wrote %q, want 1 and %q", got, stderr.String(), want) @@ -166,7 +166,7 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st var stderr bytes.Buffer - got := smallwebwaf.HealthCheck(t.Context(), nil, func(name string) (string, bool) { + got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) { value, ok := env[name] return value, ok diff --git a/internal/smallwebwaf/rproxy.go b/internal/sww/rproxy.go similarity index 95% rename from internal/smallwebwaf/rproxy.go rename to internal/sww/rproxy.go index 0c95194..77ca9e7 100644 --- a/internal/smallwebwaf/rproxy.go +++ b/internal/sww/rproxy.go @@ -1,4 +1,4 @@ -package smallwebwaf +package sww import ( "context" @@ -13,7 +13,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/rproxy" ) -// RunRProxy is `smallwebwaf rproxy`: it reads rproxy's settings, then serves +// 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 // returns the process's exit status, 1 when rproxy cannot start. Its process diff --git a/internal/smallwebwaf/rproxy_test.go b/internal/sww/rproxy_test.go similarity index 81% rename from internal/smallwebwaf/rproxy_test.go rename to internal/sww/rproxy_test.go index 228fc31..2e7810d 100644 --- a/internal/smallwebwaf/rproxy_test.go +++ b/internal/sww/rproxy_test.go @@ -1,4 +1,4 @@ -package smallwebwaf_test +package sww_test import ( "bufio" @@ -19,18 +19,18 @@ import ( "testing" "time" - "sneak.berlin/go/smallwebwaf/internal/servewaf" - "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" + "sneak.berlin/go/smallwebwaf/internal/discriminator" + "sneak.berlin/go/smallwebwaf/internal/sww" ) const ( - // wafBackend is the setting that names servewaf to rproxy. + // wafBackend is the setting that names discriminator to rproxy. wafBackend = "SWRP_WAF_BACKEND" // rproxyListenAddr is the setting that says where rproxy listens. rproxyListenAddr = "SWRP_LISTEN_ADDR" // rproxyUpstreamURL is the setting that says where the app is. rproxyUpstreamURL = "SWRP_UPSTREAM_URL" - // rproxyTimeout is the setting that says how long rproxy waits for servewaf. + // rproxyTimeout is the setting that says how long rproxy waits for discriminator. rproxyTimeout = "SWRP_TIMEOUT_MS" // bannedProbe is a path the default rule file bans a client for. bannedProbe = "/.env" @@ -38,10 +38,10 @@ const ( otherClient = "198.51.100.7" ) -// runRProxy runs `smallwebwaf rproxy` with the settings in env until ctx is +// runRProxy runs `sww rproxy` with the settings in env until ctx is // done, and returns its exit status. func runRProxy(ctx context.Context, env map[string]string, out *output) int { - return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{ + return sww.RunRProxy(ctx, sww.Params{ Version: testVersion, LookupEnv: func(name string) (string, bool) { value, ok := env[name] @@ -52,7 +52,7 @@ func runRProxy(ctx context.Context, env map[string]string, out *output) int { }) } -// startRProxy runs rproxy with servewaf at backend and the app at appURL, and +// startRProxy runs rproxy with discriminator at backend and the app at appURL, and // returns its URL and what it writes. It believes X-Forwarded-For from the // tests. It stops as the test ends, and that it stopped in order is checked. func startRProxy(t *testing.T, backend, appURL string) (string, *output) { @@ -91,10 +91,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) { return "http://" + addr, out } -// startServeWAF runs servewaf with the settings in env, and returns its address -// and URL, what it writes, and what stops it, which checks that it stopped in -// order. -func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) { +// startDiscriminator runs discriminator with the settings in env, and returns +// its address and URL, what it writes, and what stops it, which checks that it +// stopped in order. +func startDiscriminator(t *testing.T, env map[string]string) (string, *output, func()) { t.Helper() ctx, stop := context.WithCancel(t.Context()) @@ -113,17 +113,17 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func() select { case status := <-exited: if status != 0 { - t.Fatalf("servewaf's exit status %d, want 0; output:\n%s", status, out.text()) + t.Fatalf("discriminator's exit status %d, want 0; output:\n%s", status, out.text()) } case <-time.After(waitLimit): - t.Fatal("servewaf still running after being told to stop") + t.Fatal("discriminator still running after being told to stop") } } } -// serveWAFSettings are servewaf's settings with the default rule file, to listen -// at any free port. -func serveWAFSettings(t *testing.T) map[string]string { +// discriminatorSettings are discriminator's settings with the default rule +// file, to listen at any free port. +func discriminatorSettings(t *testing.T) map[string]string { t.Helper() return map[string]string{ @@ -136,7 +136,7 @@ func serveWAFSettings(t *testing.T) map[string]string { func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) { t.Parallel() - addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t)) defer stop() backend := "http://" + addr @@ -151,30 +151,32 @@ func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) { wantStatus(t, second+"/", placed, http.StatusForbidden) wantStatus(t, second+"/", otherClient, http.StatusOK) - // One request's three lines carry one id, rproxy's with servewaf's answer. + // One request's three lines carry one id, rproxy's with discriminator's answer. line := firstOut.line(t, "path", bannedProbe) - if line["disposition"] != servewaf.DispositionDeny || line["client_ip"] != placed { + if line["disposition"] != discriminator.DispositionDeny || + line["client_ip"] != placed { t.Errorf("rproxy's line is %v", line) } id, _ := line["request_id"].(string) - decision := serveWAFLine(t, serveWAFOut, id, "decision") - report := serveWAFLine(t, serveWAFOut, id, "report") + decision := discriminatorLine(t, discriminatorOut, id, "decision") + report := discriminatorLine(t, discriminatorOut, id, "report") if decision["host"] != line["host"] || decision["client_ip"] != placed || report["status"] != float64(http.StatusForbidden) { - t.Errorf("servewaf's lines are %v and %v", decision, report) + t.Errorf("discriminator's lines are %v and %v", decision, report) } } -// serveWAFLine waits for servewaf's line with request id and servewaf_line kind. -func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any { +// discriminatorLine waits for discriminator's line with request id and +// discriminator_line kind. +func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]any { t.Helper() deadline := time.Now().Add(waitLimit) for time.Now().Before(deadline) { line := out.findLine(t, func(fields map[string]any) bool { - return fields["request_id"] == id && fields["servewaf_line"] == kind + return fields["request_id"] == id && fields["discriminator_line"] == kind }) if line != nil { return line @@ -188,11 +190,11 @@ func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any { return nil } -func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { +func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T) { t.Parallel() - env := serveWAFSettings(t) - addr, _, stop := startServeWAF(t, env) + env := discriminatorSettings(t) + addr, _, stop := startDiscriminator(t, env) site, out := startRProxy(t, "http://"+addr, startApp(t)) // A visitor who keeps asking throughout, and is never refused. @@ -221,11 +223,11 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { wantStatus(t, site+"/", placed, http.StatusForbidden) stop() - // With servewaf down, the site answers, and rproxy says the default did it. + // With discriminator down, the site answers, and rproxy says the default did it. wantStatus(t, site+"/after-stop", placed, http.StatusOK) line := out.line(t, "path", "/after-stop") - if line["disposition"] != servewaf.DispositionAllow || + if line["disposition"] != discriminator.DispositionAllow || line["default_disposition"] != true { t.Errorf("rproxy's line is %v", line) } @@ -233,7 +235,7 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { // Back at its address, with its state files, it applies the ban again. restarted := maps.Clone(env) restarted[listenAddr] = addr - _, _, stop = startServeWAF(t, restarted) + _, _, stop = startDiscriminator(t, restarted) defer stop() @@ -306,10 +308,10 @@ func sendRaw(t *testing.T, site, request string) int { return res.StatusCode } -func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { +func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) { t.Parallel() - addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t)) defer stop() site, _ := startRProxy(t, "http://"+addr, startApp(t)) @@ -318,7 +320,7 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { const smuggledID = "smuggled-request" smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" + - servewaf.ClientHeader + ": " + otherClient + "\r\n" + + discriminator.ClientHeader + ": " + otherClient + "\r\n" + "X-Request-ID: " + smuggledID + "\r\n\r\n" status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+ @@ -334,18 +336,18 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { time.Sleep(pollInterval) } - line := serveWAFOut.findLine(t, func(fields map[string]any) bool { + line := discriminatorOut.findLine(t, func(fields map[string]any) bool { return fields["request_id"] == smuggledID }) if line != nil { - t.Errorf("servewaf read the body as a request: %v", line) + t.Errorf("discriminator read the body as a request: %v", line) } } -func TestATargetThatCannotGoToServeWAFIsRefusedForABannedClient(t *testing.T) { +func TestATargetThatCannotGoToDiscriminatorIsRefusedForABannedClient(t *testing.T) { t.Parallel() - addr, _, stop := startServeWAF(t, serveWAFSettings(t)) + addr, _, stop := startDiscriminator(t, discriminatorSettings(t)) defer stop() site, out := startRProxy(t, "http://"+addr, startApp(t)) @@ -369,7 +371,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) { const zoned = "2001:db8::1%eth0" - addr, _, stop := startServeWAF(t, serveWAFSettings(t)) + addr, _, stop := startDiscriminator(t, discriminatorSettings(t)) defer stop() site, out := startRProxy(t, "http://"+addr, startApp(t)) @@ -384,7 +386,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) { } } -func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) { +func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) { t.Parallel() const status = 799 @@ -395,7 +397,7 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) { })) t.Cleanup(app.Close) - addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t)) defer stop() site, out := startRProxy(t, "http://"+addr, app.URL) @@ -409,13 +411,13 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) { id, _ := line["request_id"].(string) - report := serveWAFLine(t, serveWAFOut, id, "report") + report := discriminatorLine(t, discriminatorOut, id, "report") if report["status"] != 599.0 { - t.Errorf("servewaf's line is %v, want status 599", report) + t.Errorf("discriminator's line is %v, want status 599", report) } } -// startRProxyProcess runs `smallwebwaf rproxy` in a process of its own, with the +// startRProxyProcess runs `sww rproxy` in a process of its own, with the // settings in env, each as NAME=value. It returns where rproxy listens, the // process, and what rproxy writes. func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) { @@ -453,16 +455,16 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output return addr, cmd, out } -func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { +func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) { t.Parallel() protocols := make(chan string, 10) backend := httptest.NewUnstartedServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(servewaf.ClientHeader) != "" { + if r.Header.Get(discriminator.ClientHeader) != "" { protocols <- r.Proto - w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionDeny) + w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionDeny) w.WriteHeader(http.StatusForbidden) } })) @@ -472,7 +474,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { // rproxy has no setting for the certificates it trusts, so it is told of this // one as Go's client is told of any, in a process of its own. - certificates := filepath.Join(t.TempDir(), "servewaf.pem") + certificates := filepath.Join(t.TempDir(), "discriminator.pem") err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: backend.Certificate().Raw, @@ -494,7 +496,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { } { status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n") if status != http.StatusForbidden { - t.Errorf("with %s: status %d, want servewaf's 403", name, status) + t.Errorf("with %s: status %d, want discriminator's 403", name, status) } select { @@ -503,7 +505,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol) } case <-time.After(waitLimit): - t.Errorf("with %s: servewaf was not asked", name) + t.Errorf("with %s: discriminator was not asked", name) } } } @@ -532,8 +534,8 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) { release := make(chan struct{}) backend := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(servewaf.ClientHeader) != "" { - w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow) + if r.Header.Get(discriminator.ClientHeader) != "" { + w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow) return } @@ -640,7 +642,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow) + w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow) })) t.Cleanup(backend.Close) @@ -703,7 +705,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) { {"no backend", wafBackend, nil}, { "another invalid", rproxyTimeout, - map[string]string{wafBackend: "http://servewaf:8080", rproxyTimeout: "soon"}, + map[string]string{wafBackend: "http://discriminator:8080", rproxyTimeout: "soon"}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -740,7 +742,7 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) { out := &output{} status := runRProxy(t.Context(), map[string]string{ - wafBackend: "http://servewaf:8080", + wafBackend: "http://discriminator:8080", rproxyListenAddr: taken.Addr().String(), }, out) if status != 1 { @@ -757,7 +759,7 @@ func TestMainRunsRProxy(t *testing.T) { addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9") req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, - "http://"+addr+servewaf.HealthPath, http.NoBody) + "http://"+addr+discriminator.HealthPath, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } diff --git a/internal/smallwebwaf/smallwebwaf.go b/internal/sww/sww.go similarity index 82% rename from internal/smallwebwaf/smallwebwaf.go rename to internal/sww/sww.go index c7ae2e0..5cd7376 100644 --- a/internal/smallwebwaf/smallwebwaf.go +++ b/internal/sww/sww.go @@ -1,11 +1,11 @@ -// Package smallwebwaf runs the smallwebwaf process, as the command it is given. -// `smallwebwaf servewaf` reads the settings, the rule files, the lookup database +// Package sww runs the sww process, as the command it is given. +// `sww discriminator` reads the settings, the rule files, the lookup database // and the state files, answers rproxy's questions until it is told to stop, and -// then stops in an orderly way, writing the state files. `smallwebwaf rproxy` +// then stops in an orderly way, writing the state files. `sww rproxy` // reads rproxy's settings alone, and runs the reverse proxy in front of one app -// that asks servewaf about each request. `smallwebwaf healthcheck` is the +// that asks discriminator about each request. `sww healthcheck` is the // container's health check. -package smallwebwaf +package sww import ( "context" @@ -22,40 +22,40 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/rules" - "sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/waf" ) // shutdownTimeout is how long requests in progress may take to finish -// once smallwebwaf is told to stop, before their connections are closed. +// once sww is told to stop, before their connections are closed. // runit and docker wait a little longer before they kill the process. const shutdownTimeout = 5 * time.Second -// remoteLogStopTimeout is how long, as servewaf stops, the log lines still -// waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has +// 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 // carried them. const remoteLogStopTimeout = 2 * time.Second -// usageStatus is the exit status with no command, or one smallwebwaf does not +// usageStatus is the exit status with no command, or one sww does not // know. const usageStatus = 2 -// usage is what smallwebwaf prints then. -const usage = `usage: smallwebwaf +// usage is what sww prints then. +const usage = `usage: sww commands: - rproxy the reverse proxy in front of one app - servewaf decides for rproxy, and keeps bans, counters and history - healthcheck the container's health check + rproxy the reverse proxy in front of one app + discriminator decides for rproxy, and keeps bans, counters and history + healthcheck the container's health check ` -// Params are what RunServeWAF and RunRProxy need from the process. +// Params are what RunDiscriminator and RunRProxy need from the process. type Params struct { // Version is the version of the binary, set when it is built. Version string @@ -66,7 +66,7 @@ type Params struct { } // Main runs the command os.Args names, and returns the process's exit status. -// `smallwebwaf rproxy` and `smallwebwaf servewaf` run until SIGTERM or SIGINT. +// `sww rproxy` and `sww discriminator` run until SIGTERM or SIGINT. // With no command, or one it does not know, it prints the commands and returns // usageStatus. func Main(version string) int { @@ -84,8 +84,8 @@ func Main(version string) int { switch args[0] { case "rproxy": return RunRProxy(ctx, params) - case "servewaf": - return RunServeWAF(ctx, params) + case "discriminator": + return RunDiscriminator(ctx, params) case "healthcheck": return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr) default: @@ -100,12 +100,12 @@ func printUsage(w io.Writer) int { return usageStatus } -// RunServeWAF is `smallwebwaf servewaf`: it reads the settings, the rule files, +// RunDiscriminator is `sww discriminator`: it reads the settings, the rule files, // the lookup database and the state files, then serves requests until ctx is -// done. It returns the process's exit status, 1 when servewaf cannot start. -func RunServeWAF(ctx context.Context, params Params) 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 SWWAF_LOG_LEVEL lets through. + // error, which every SWW_LOG_LEVEL lets through. processLog := requestlog.NewProcessLogger(params.Stdout, config.InstanceName(params.LookupEnv), slog.LevelError) @@ -116,7 +116,7 @@ func RunServeWAF(ctx context.Context, params Params) int { return 1 } - // While SWWAF_LOG_REMOTE_URL is set, every line on stdout from here on + // While SWW_LOG_REMOTE_URL is set, every line on stdout from here on // is sent there too. stdout := params.Stdout @@ -151,7 +151,7 @@ func RunServeWAF(ctx context.Context, params Params) int { return 1 } - crs, err := servewaf.NewCoreRuleSet(cfg) + crs, err := discriminator.NewCoreRuleSet(cfg) if err != nil { processLog.Error("cannot use the Core Rule Set", "error", err.Error()) @@ -178,7 +178,7 @@ func RunServeWAF(ctx context.Context, params Params) int { listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) if err != nil { - processLog.Error("cannot listen on SWWAF_LISTEN_ADDR", "error", err.Error()) + processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error()) return 1 } @@ -191,14 +191,14 @@ func RunServeWAF(ctx context.Context, params Params) int { return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog) } -// newServer returns the server servewaf runs, with the metrics of the alerts, -// after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup +// 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 // database that cannot be read is an error. func newServer( cfg *config.Config, stdout io.Writer, processLog *slog.Logger, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, crs *waf.CoreRuleSet, -) (*servewaf.Server, error) { +) (*discriminator.Server, error) { var lookupFile *lookup.File if cfg.LookupSource == "file" { @@ -215,7 +215,7 @@ func newServer( } } - server := servewaf.New(servewaf.Params{ + server := discriminator.New(discriminator.Params{ Config: cfg, RequestLog: stdout, ProcessLog: processLog, @@ -239,7 +239,7 @@ func newServer( // loadStateFiles reads the state files into the parts of server and into // alertQueue, as state.Load does. func loadStateFiles( - cfg *config.Config, server *servewaf.Server, alertQueue *alerts.Queue, + cfg *config.Config, server *discriminator.Server, alertQueue *alerts.Queue, now func() time.Time, processLog *slog.Logger, ) (*state.Files, error) { return state.Load(state.Params{ @@ -281,7 +281,7 @@ func newAlertQueue( } // newRemoteLogSender returns a sender of the log lines to -// SWWAF_LOG_REMOTE_URL, with the settings for it. +// SWW_LOG_REMOTE_URL, with the settings for it. func newRemoteLogSender(cfg *config.Config) *remotelog.Sender { return remotelog.New(remotelog.Params{ URL: cfg.LogRemoteURL, @@ -295,7 +295,7 @@ func newRemoteLogSender(cfg *config.Config) *remotelog.Sender { // startSending runs remote until the function it returns is called, which // then waits at most remoteLogStopTimeout for the lines still waiting to // be sent. Sending goes on after ctx is done, so that the lines written -// while smallwebwaf stops are sent too. +// while sww stops are sent too. func startSending( ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger, ) func() { @@ -325,7 +325,7 @@ func startSending( // shutdownTimeout to finish, and writes every state file, alerts.json with // the alerts still waiting. func serve( - ctx context.Context, server *servewaf.Server, listener net.Listener, + ctx context.Context, server *discriminator.Server, listener net.Listener, files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue, processLog *slog.Logger, ) int { diff --git a/internal/smallwebwaf/smallwebwaf_internal_test.go b/internal/sww/sww_internal_test.go similarity index 99% rename from internal/smallwebwaf/smallwebwaf_internal_test.go rename to internal/sww/sww_internal_test.go index aea6bfe..06bb737 100644 --- a/internal/smallwebwaf/smallwebwaf_internal_test.go +++ b/internal/sww/sww_internal_test.go @@ -1,4 +1,4 @@ -package smallwebwaf +package sww import ( "log/slog" diff --git a/internal/smallwebwaf/smallwebwaf_test.go b/internal/sww/sww_test.go similarity index 84% rename from internal/smallwebwaf/smallwebwaf_test.go rename to internal/sww/sww_test.go index 73ebc9a..59059b5 100644 --- a/internal/smallwebwaf/smallwebwaf_test.go +++ b/internal/sww/sww_test.go @@ -1,4 +1,4 @@ -package smallwebwaf_test +package sww_test import ( "bytes" @@ -20,9 +20,9 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" - "sneak.berlin/go/smallwebwaf/internal/servewaf" - "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" + "sneak.berlin/go/smallwebwaf/internal/sww" ) const ( @@ -30,25 +30,25 @@ const ( waitLimit = 10 * time.Second // pollInterval is how often a test looks for a line. pollInterval = 10 * time.Millisecond - // testVersion is the version the tests give smallwebwaf. + // testVersion is the version the tests give sww. testVersion = "test" // localhost is where the tests listen. localhost = "127.0.0.1" - listenAddr = "SWWAF_LISTEN_ADDR" - stateDir = "SWWAF_STATE_DIR" - stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" - stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" - rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" - rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" - rulesDir = "SWWAF_RULES_DIR" - lookupSource = "SWWAF_LOOKUP_SOURCE" - lookupDBPath = "SWWAF_LOOKUP_DB_PATH" - instanceName = "SWWAF_INSTANCE_NAME" - adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name - metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name - // adminSecret is the SWWAF_ADMIN_TOKEN the tests set. + 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. adminSecret = "fedcba9876543210fedcba9876543210" - // instance is the SWWAF_INSTANCE_NAME the tests set where they look at + // instance is the SWW_INSTANCE_NAME the tests set where they look at // it. instance = "fsn1app1/blog" // greeting is what the tests' app answers. @@ -57,13 +57,13 @@ const ( placed = "203.0.113.9" ) -// output collects what smallwebwaf writes on stdout. +// output collects what sww writes on stdout. type output struct { mu sync.Mutex buf bytes.Buffer } -// Write adds lines smallwebwaf writes. +// Write adds lines sww writes. func (o *output) Write(p []byte) (int, error) { o.mu.Lock() defer o.mu.Unlock() @@ -123,15 +123,15 @@ func (o *output) text() string { return o.buf.String() } -// run runs `smallwebwaf servewaf` with the settings in env until ctx is done, -// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it, +// 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, // so that no test sends GeoJS its clients' addresses. func run(ctx context.Context, env map[string]string, out *output) int { - return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{ + return sww.RunDiscriminator(ctx, sww.Params{ Version: testVersion, LookupEnv: func(name string) (string, bool) { value, ok := env[name] - if !ok && name == "SWWAF_LOOKUP_SOURCE" { + if !ok && name == "SWW_LOOKUP_SOURCE" { return "off", true } @@ -147,8 +147,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) { out := &output{} status := run(t.Context(), map[string]string{ - "SWWAF_BYTES_LIMIT_PER_MINUTE": "lots", - instanceName: instance, + "SWW_BYTES_LIMIT_PER_MINUTE": "lots", + instanceName: instance, }, out) if status != 1 { t.Errorf("exit status %d, want 1", status) @@ -159,7 +159,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) { if line["type"] != "process" || line["instance"] != instance || line["level"] != "ERROR" || - !strings.HasPrefix(message, "SWWAF_BYTES_LIMIT_PER_MINUTE: ") { + !strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") { t.Errorf("start refused with %v", line) } } @@ -215,7 +215,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) { t.Errorf("exit status %d, want 1", status) } - out.line(t, "msg", "cannot listen on SWWAF_LISTEN_ADDR") + out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR") } func TestServesUntilToldToStop(t *testing.T) { @@ -264,7 +264,7 @@ func TestServesUntilToldToStop(t *testing.T) { func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) { t.Parallel() - // A list that cannot be fetched has a warning written once smallwebwaf + // A list that cannot be fetched has a warning written once sww // serves, after its starting line. lists := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { @@ -278,11 +278,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) { go func() { exited <- run(ctx, map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWWAF_BLOCKLIST_URLS": lists.URL + "/tor.txt", - "SWWAF_LOG_LEVEL": "warn", + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + "SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt", + "SWW_LOG_LEVEL": "warn", }, out) }() @@ -325,7 +325,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) { var metrics string out := runUntilStopped(t, env, func(url string) { - metrics = metricsText(t, url+"_smallwebwaf/metrics", token) + metrics = metricsText(t, url+"_sww/metrics", token) }) // The process's lines from its start to its stop, and the request's. @@ -357,7 +357,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) { rulesDir: t.TempDir(), rateLimitPerDay: "2", // Neither comes due in the test: the files are written as - // smallwebwaf stops. + // sww stops. stateWriteDelay: "1h", stateCounterInterval: "1h", } @@ -385,7 +385,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) { func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { t.Parallel() - const scope = "SWWAF_BAN_SCOPE_V4_PREFIX" + const scope = "SWW_BAN_SCOPE_V4_PREFIX" env := map[string]string{ listenAddr: localhost + ":0", @@ -462,13 +462,13 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) { rulesDir: t.TempDir(), adminToken: adminSecret, // Neither comes due in the test: the files are written as - // smallwebwaf stops. + // sww stops. stateWriteDelay: "1h", stateCounterInterval: "1h", } runUntilStopped(t, env, func(url string) { - askAsAdmin(t, http.MethodPost, url+"_smallwebwaf/bans", + askAsAdmin(t, http.MethodPost, url+"_sww/bans", `{"netblock": "203.0.113.0/24", "duration": "permanent", `+ `"reason": "probes for logins"}`) wantStatus(t, url, "203.0.113.9", http.StatusForbidden) @@ -485,7 +485,7 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) { // more, and bans.json keeps it, marked lifted. runUntilStopped(t, env, func(url string) { wantStatus(t, url, "203.0.113.9", http.StatusForbidden) - askAsAdmin(t, http.MethodDelete, url+"_smallwebwaf/bans/203.0.113.9", "") + askAsAdmin(t, http.MethodDelete, url+"_sww/bans/203.0.113.9", "") wantStatus(t, url, "203.0.113.9", http.StatusOK) }) @@ -505,14 +505,14 @@ 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. - "SWWAF_RATE_LIMIT_PER_MINUTE": "off", + "SWW_RATE_LIMIT_PER_MINUTE": "off", } out := runUntilStopped(t, env, func(url string) { wantAllowed(t, url) // Written once: each change would start the rule files' wait - // again. A file written before smallwebwaf watches the directory is + // again. A file written before sww watches the directory is // read once it does. err := os.WriteFile(filepath.Join(dir, "50-app.rules"), []byte("everything path block ^/\n"), 0o600) @@ -548,7 +548,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { dir := filepath.Join(t.TempDir(), "rules.d") - wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ + wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+ ": no such file or directory") } @@ -560,14 +560,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) { status := run(t.Context(), map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), - "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path, + "SWW_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 := "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " + + want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " + path + ": no such file or directory" if line["error"] != want { @@ -592,7 +592,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS status := run(t.Context(), map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), - "SWWAF_WAF_MODE": config.WAFModeOff, "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE": path, + "SWW_WAF_MODE": config.WAFModeOff, "SWW_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, - "SWWAF_DENIED_COUNTRIES": "kp", + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + metricsToken: token, + instanceName: instance, + lookupSource: "file", + lookupDBPath: path, + "SWW_DENIED_COUNTRIES": "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. @@ -634,7 +634,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { runUntilStopped(t, env, func(url string) { wantStatus(t, url, placed, http.StatusOK) - // smallwebwaf reads the file again once it watches its directory, + // sww reads the file again once it watches its directory, // which may be after the first replacement. Once that has taken // effect, only the watch can show the next. Each takes as long as // it takes, so that a slow test process cannot fail the test. @@ -660,10 +660,10 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { rename(t, replacement, path) - metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, - `smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`) + metrics := metricsWith(t, url+"_sww/metrics", token, + `sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`) lastRead = seriesValue(t, metrics, - `smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`) + `sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`) wantStatus(t, url, placed, http.StatusOK) }) @@ -708,33 +708,33 @@ 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, - "SWWAF_BLOCKLIST_URLS": torURL, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: t.TempDir(), + metricsToken: token, + instanceName: instance, + "SWW_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. rateLimitExemptNets: placed + "," + localhost, } - failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/blog",` + + failures := `sww_reputation_failures_total{instance="fsn1app1/blog",` + `source="` + torURL + `"} ` // tor.txt cannot be fetched at first, which is counted. failing.Store(true) runUntilStopped(t, env, func(url string) { - metricsWith(t, url+"_smallwebwaf/metrics", token, failures+"1") + metricsWith(t, url+"_sww/metrics", token, failures+"1") }) // Restarted with drop.txt named after it and the server answering, tor.txt - // waits SWWAF_BLOCKLIST_REFRESH after its failed try, kept in reputation.json, + // waits SWW_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["SWWAF_BLOCKLIST_URLS"] = torURL + "," + dropURL + env["SWW_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(), - "SWWAF_CROWDSEC_LAPI_URL": engine.URL, - "SWWAF_CROWDSEC_LAPI_KEY": bouncerKey, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: t.TempDir(), + "SWW_CROWDSEC_LAPI_URL": engine.URL, + "SWW_CROWDSEC_LAPI_KEY": bouncerKey, } // Once the list is fetched, the client's request bans it. @@ -867,7 +867,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) { t.Fatalf("exit status %d, want 1", status) } - want := "SWWAF_LOOKUP_DB_PATH cannot be read: open " + path + + want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path + ": no such file or directory" line := out.line(t, "msg", "cannot use the lookup database") @@ -889,10 +889,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { }() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: t.TempDir(), + "SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), } out := runUntilStopped(t, env, func(url string) { @@ -900,7 +900,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { }) out.line(t, "type", "request") - // smallwebwaf connected as it started, and closes the connection once + // sww connected as it started, and closes the connection once // it has sent the lines written as it stopped. conn, err := endpoint.Accept() if err != nil { @@ -944,13 +944,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { }() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: t.TempDir(), - rulesDir: t.TempDir(), - "SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), - "SWWAF_LOG_REMOTE_BUFFER": "1", - metricsToken: token, - instanceName: instance, + 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, } out := runUntilStopped(t, env, func(url string) { @@ -958,17 +958,17 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) { // More than one line has been written, and the buffer holds the // last. - metrics := metricsText(t, url+"_smallwebwaf/metrics", token) + metrics := metricsText(t, url+"_sww/metrics", token) for _, series := range []string{ - `smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`, - `smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`, + `sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`, + `sww_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`, } { if !strings.Contains(metrics, "\n"+series+"\n") { t.Errorf("no %q in the metrics:\n%s", series, metrics) } } - const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" + + const dropped = "\nsww_remote_log_lines_dropped_total" + `{instance="fsn1app1/blog"} ` if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) { t.Errorf("no line dropped in the metrics:\n%s", metrics) @@ -1003,12 +1003,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { dir := t.TempDir() env := map[string]string{ - listenAddr: localhost + ":0", - stateDir: dir, - rulesDir: rules, - "SWWAF_ALERT_WEBHOOK_URL": webhook.url, - "SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, - instanceName: instance, + listenAddr: localhost + ":0", + stateDir: dir, + rulesDir: rules, + "SWW_ALERT_WEBHOOK_URL": webhook.url, + "SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, + instanceName: instance, } runUntilStopped(t, env, func(url string) { @@ -1029,8 +1029,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { webhook.waitFor(t, "permanent_ban", false) }) - // alerts.json keeps it as smallwebwaf stops, and once started again, - // smallwebwaf sends it. + // alerts.json keeps it as sww stops, and once started again, + // sww sends it. var file struct { Waiting map[string][]struct { Event string `json:"event"` @@ -1056,7 +1056,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { webhook.failing.Store(false) - env["SWWAF_ALLOW_NETS"] = localhost + env["SWW_ALLOW_NETS"] = localhost env[metricsToken] = token runUntilStopped(t, env, func(url string) { @@ -1064,11 +1064,11 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}` - metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, - "\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n") + metrics := metricsWith(t, url+"_sww/metrics", token, + "\nsww_alerts_sent_total"+ofWebhook+" 1\n") for _, series := range []string{"failed", "suppressed", "dropped"} { - zero := "\nsmallwebwaf_alerts_" + series + "_total" + ofWebhook + " 0\n" + zero := "\nsww_alerts_" + series + "_total" + ofWebhook + " 0\n" if !strings.Contains(metrics, zero) { t.Errorf("no %q in the metrics:\n%s", zero, metrics) } @@ -1092,12 +1092,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { rulesDir: t.TempDir(), rateLimitPerDay: "1", // The metrics are read from 127.0.0.1, which no limit counts. - "SWWAF_ALLOW_NETS": localhost + "/32", - metricsToken: token, - instanceName: instance, - "SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url, - "SWWAF_ALERT_NTFY_URL": ntfy.url, - "SWWAF_ALERT_NTFY_TOKEN": ntfyToken, + "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, } runUntilStopped(t, env, func(url string) { @@ -1130,13 +1130,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { // webhook, which is not set. As long as that takes, so that a slow // test process cannot fail the test. sent := []string{ - "\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," + + "\nsww_alerts_sent_total{destination=\"slack\"," + "instance=\"fsn1app1/blog\"} 1\n", - "\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," + + "\nsww_alerts_sent_total{destination=\"ntfy\"," + "instance=\"fsn1app1/blog\"} 1\n", } - metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...) + metrics := metricsWith(t, url+"_sww/metrics", token, sent...) if strings.Contains(metrics, `destination="webhook"`) { t.Errorf("the metrics give the webhook:\n%s", metrics) } @@ -1162,10 +1162,10 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) { t.Parallel() wantStartRefused(t, filepath.Join(t.TempDir(), "missing"), - "SWWAF_STATE_DIR cannot be written: ") + "SWW_STATE_DIR cannot be written: ") } -// wantStartRefused runs smallwebwaf with its state files in dir, and +// wantStartRefused runs sww with its state files in dir, and // checks that it stops at start, with an error that starts with want. If // it starts instead, it is stopped after waitLimit. func wantStartRefused(t *testing.T, dir, want string) { @@ -1191,7 +1191,7 @@ func wantStartRefused(t *testing.T, dir, want string) { } } -// wantRulesRefused runs smallwebwaf with its rule files in dir, and +// wantRulesRefused runs sww with its rule files in dir, and // checks that it stops at start, with the error want. If it starts // instead, it is stopped after waitLimit. func wantRulesRefused(t *testing.T, dir, want string) { @@ -1229,7 +1229,7 @@ func startApp(t *testing.T) string { return app.URL } -// runUntilStopped runs `smallwebwaf servewaf` with the settings in env, has use +// runUntilStopped runs `sww discriminator` with the settings in env, has use // send it requests at url, then stops it as SIGTERM does, checks that it // stopped in order, and returns its output. func runUntilStopped( @@ -1268,28 +1268,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, - "SWWAF_MODE": "enforce", - stateWriteDelay: "10s", - stateCounterInterval: "15m", - "SWWAF_ALLOW_NETS": "", - rateLimitExemptNets: "", - "SWWAF_DENY_NETS": "", - "SWWAF_RATE_LIMIT_PER_MINUTE": "120", - "SWWAF_RATE_LIMIT_PER_HOUR": "1500", - rateLimitPerDay: "6000", - "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", - "SWWAF_DENIED_COUNTRIES": "", - "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", - "SWWAF_BAN_RESPONSE": "403", - "SWWAF_LIMIT_BAN_DURATION": "1h", - "SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h", - "SWWAF_MAX_BAN_DURATION": "7d", - "SWWAF_ATTACK_BAN_DURATION": "7d", - "SWWAF_MAX_BANS": "5000", - "SWWAF_BAN_SCOPE_V4_PREFIX": "32", - "SWWAF_RULES_ENABLED": "true", + 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_DENIED_COUNTRIES": "", + "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", } for name, value := range want { @@ -1303,7 +1303,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) { } } -// wantAllowed checks that servewaf at url allows a request from localhost. +// wantAllowed checks that discriminator at url allows a request from localhost. func wantAllowed(t *testing.T, url string) { t.Helper() @@ -1488,8 +1488,8 @@ func onlyBan(t *testing.T, dir string) map[string]any { return file.Bans[0] } -// wantRefused checks that servewaf at url refuses a request from localhost with -// 403, the default SWWAF_BAN_RESPONSE. +// wantRefused checks that discriminator at url refuses a request from localhost with +// 403, the default SWW_BAN_RESPONSE. func wantRefused(t *testing.T, url string) { t.Helper() @@ -1510,7 +1510,7 @@ func wantStatus(t *testing.T, url, from string, status int) { // saveUntilAnswered writes content to the state file at path, as an // admin saves an edit of it, until a request to url from the client at // from is answered with status. The file is written again before each -// request, since smallwebwaf may not watch its directory yet when it is +// request, since sww may not watch its directory yet when it is // first written. It waits as long as that takes, so that a slow test // process cannot fail the test. func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) { @@ -1530,8 +1530,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int } } -// destination is a stand-in for SWWAF_ALERT_SLACK_WEBHOOK_URL or -// SWWAF_ALERT_NTFY_URL. It notes each request it is sent, and answers +// 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 // 200. type destination struct { url string @@ -1590,7 +1590,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost { } } -// webhook is a stand-in for SWWAF_ALERT_WEBHOOK_URL. It notes each alert +// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert // it is sent, and answers 204, or 503 while failing. type webhook struct { url string @@ -1665,8 +1665,8 @@ func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost } // statusFrom returns the status a request to url from the client at from is -// answered with. The request serves rproxy and servewaf alike. rproxy reads the -// client from X-Forwarded-For, and replaces the other two headers. To servewaf, +// answered with. The request serves rproxy and discriminator alike. rproxy reads the +// client from X-Forwarded-For, and replaces the other two headers. To discriminator, // they make it a question about that client. func statusFrom(t *testing.T, url, from string) int { t.Helper() @@ -1678,7 +1678,7 @@ func statusFrom(t *testing.T, url, from string) int { } req.Header.Set("X-Forwarded-For", from) - req.Header.Set(servewaf.ClientHeader, from) + req.Header.Set(discriminator.ClientHeader, from) req.Header.Set("X-Request-ID", rand.Text()) transport := &http.Transport{} diff --git a/internal/waf/waf.go b/internal/waf/waf.go index 0f12aab..7b1cf79 100644 --- a/internal/waf/waf.go +++ b/internal/waf/waf.go @@ -1,11 +1,11 @@ // Package waf 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 SWWAF_WAF_BODY_LIMIT is set, with the eight changes smallwebwaf -// makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as +// 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 // "Attack detection" under "Settings" in SPEC.md describes them. It reads no // response. // -// smallwebwaf writes only to its state directory, so Coraza is built with +// sww writes only to its state directory, so Coraza is built with // its no_fs_access tag, as the Dockerfile and script/build build it: of a // file in a multipart body, Coraza then counts the bytes instead of // writing them to the system's temporary directory. @@ -30,18 +30,18 @@ import ( "github.com/corazawaf/coraza/v3/types" ) -// directives are the Core Rule Set as smallwebwaf runs it, with the +// directives are the Core Rule Set as sww runs it, with the // paranoia level for %d, bodyDirectives for the first %s while -// SWWAF_WAF_BODY_LIMIT is set, the exclusions file for the second, and +// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and // giteaExclusions for the third. -// Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the +// 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 -// SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no +// SWW_WAF_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. smallwebwaf compares the request's anomaly -# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode +# The engine only detects. sww compares the request's anomaly +# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # alike. It reads no body, unless bodyDirectives switch that on. SecRuleEngine DetectionOnly SecRequestBodyAccess Off @@ -183,21 +183,21 @@ var cookiesNotRead = []string{"gitea_flash", "redirect_to"} // Params are what New needs. type Params struct { - // ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4. + // ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4. ParanoiaLevel int // DisabledRules are the ids of the rules switched off - // (SWWAF_WAF_DISABLED_RULES). + // (SWW_WAF_DISABLED_RULES). DisabledRules []int // BodyLimit is the most of a request body the Core Rule Set reads - // (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. + // (SWW_WAF_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 (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while + // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // there is none. ExclusionsFile string } -// The ids of smallwebwaf's own rules, which an exclusions file may not +// The ids of sww's own rules, which an exclusions file may not // name. const ( firstOwnRuleID = 900000 @@ -209,16 +209,16 @@ const ( // end of a range may have one. var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`) -// errSmallwebwafRuleID is the error of an exclusions file that names an -// id of smallwebwaf's own rules. -var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules") +// errSwwRuleID is the error of an exclusions file that names an +// id of sww's own rules. +var errSwwRuleID = errors.New("an id of sww's own rules") // errChangesNoRule is the error of an exclusions file that holds a // directive that removes or changes rules as it loads, such as // 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 SWWAF_WAF_DISABLED_RULES, or on some requests " + + "switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + "with a ctl exclusion") // directivesIn returns the directives in text as Coraza reads them: it @@ -249,7 +249,7 @@ func directivesIn(text string) []string { } // checkExclusions returns an error if exclusions, the text of an -// exclusions file, names an id of smallwebwaf's own rules, alone or in a +// exclusions file, names an id of sww's own rules, alone or in a // range, or holds a directive whose name starts with SecRuleRemove or // SecRuleUpdate. func checkExclusions(exclusions string) error { @@ -264,7 +264,7 @@ func checkExclusions(exclusions string) error { if first <= lastOwnRuleID && last >= firstOwnRuleID { return fmt.Errorf("the exclusions file names %s: %w", match[0], - errSmallwebwafRuleID) + errSwwRuleID) } } @@ -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 SWWAF_WAF_BODY_LIMIT is set and the body is +// the request's headers, while SWW_WAF_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/waf/waf_test.go b/internal/waf/waf_test.go index b8eb319..3a46ac7 100644 --- a/internal/waf/waf_test.go +++ b/internal/waf/waf_test.go @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/waf" ) -// defaultDisabledRules are the rules SWWAF_WAF_DISABLED_RULES switches off +// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off // by default. // //nolint:gochecknoglobals // a constant cannot be a list @@ -112,7 +112,7 @@ func matched(ids ...int) waf.Result { return waf.Result{RuleIDs: ids, Score: critical * len(ids)} } -// atDefaults returns the Core Rule Set as smallwebwaf runs it by default. +// atDefaults returns the Core Rule Set as sww runs it by default. func atDefaults(t *testing.T) *waf.CoreRuleSet { t.Helper() @@ -455,7 +455,7 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) { for _, tc := range []struct{ text, want string }{ {"SecNoSuchDirective On\n", "unknown directive"}, - // smallwebwaf's own rules may not be switched off or changed, by + // sww's own rules may not be switched off or changed, by // id or in a range, on one line or split over several. {"SecRuleRemoveById 899999-901000\n", "names 899999-901000"}, { @@ -525,7 +525,7 @@ func giteaRequests(t *testing.T) []request { func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { t.Parallel() - const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default crs := atDefaults(t) @@ -550,7 +550,7 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { } } -// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies. +// bodyLimit is SWW_WAF_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. @@ -564,7 +564,7 @@ const ( // injection is an SQL injection, which rule 942100 matches. const injection = "1' OR '1'='1" -// readingBodies returns the Core Rule Set as smallwebwaf runs it by +// readingBodies returns the Core Rule Set as sww runs it by // default, but reading bodies up to bodyLimit. func readingBodies(t *testing.T) *waf.CoreRuleSet { t.Helper() diff --git a/script/build b/script/build index 0f2f02c..ff372b6 100755 --- a/script/build +++ b/script/build @@ -1,5 +1,5 @@ #!/bin/sh -# script/build: build bin/smallwebwaf on the host, with Go installed, for +# script/build: build bin/sww on the host, with Go installed, for # working on the code by hand. The version it reports comes from git, as # in script/docker, and the no_fs_access tag is the Dockerfile's. set -eu @@ -12,7 +12,7 @@ main() { version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \ - -o bin/smallwebwaf ./cmd/smallwebwaf + -o bin/sww ./cmd/sww } main "$@" diff --git a/script/example-app b/script/example-app index da3eafd..9ac4fff 100755 --- a/script/example-app +++ b/script/example-app @@ -1,25 +1,25 @@ #!/bin/sh # script/example-app: build the image, and on it the example app in # deploy/example-app, then run the app's container, with rproxy in it, and -# servewaf in a container of its own with a volume for the state files. It +# discriminator in a container of its own with a volume for the state files. It # checks: # - that the health checks pass; # - that a request is served through rproxy, and a second one in a minute # bans the client; # - that a probe for /.env bans another client, which its next request bans # for good; -# - that only servewaf's container changes the state directory's owner; -# - that the app still answers through rproxy with servewaf stopped, and -# that once servewaf has started again, bans apply again; +# - that only discriminator's container changes the state directory's owner; +# - that the app still answers through rproxy with discriminator stopped, and +# that once discriminator has started again, bans apply again; # - that `sv stop` stops rproxy in order, and `docker stop` stops each # container without having to kill it; -# - that a new servewaf container on the same volume still refuses the banned +# - that a new discriminator container on the same volume still refuses the banned # client; -# - that a servewaf container with its state files on a new volume mounted at -# the directory SWWAF_STATE_DIR_FILE names becomes healthy; -# - that one whose SWWAF_STATE_DIR does not exist logs servewaf's message +# - 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 # about it. -# servewaf runs with SWWAF_LOOKUP_SOURCE=off, so that no address is sent to +# discriminator runs with SWW_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. @@ -35,15 +35,15 @@ IMAGE="$NAME-base" APP_IMAGE="$NAME-app" NETWORK="$NAME" APP="$NAME-app-container" -SERVEWAF="$NAME-servewaf" +DISCRIMINATOR="$NAME-discriminator" VOLUME="$NAME-state" -# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE +# The volumes of the container whose state directory SWW_STATE_DIR_FILE # names: its state files, and the file that names their directory. MOVED_VOLUME="$NAME-moved-state" SETTINGS_VOLUME="$NAME-settings" cleanup() { - docker rm --force "$APP" "$SERVEWAF" >/dev/null 2>&1 || true + docker rm --force "$APP" "$DISCRIMINATOR" >/dev/null 2>&1 || true docker network rm "$NETWORK" >/dev/null 2>&1 || true docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \ >/dev/null 2>&1 || true @@ -53,8 +53,8 @@ cleanup() { fail() { echo "example-app: $*; the app container's output:" >&2 docker logs "$APP" >&2 || true - echo "example-app: servewaf's output:" >&2 - docker logs "$SERVEWAF" >&2 || true + echo "example-app: discriminator's output:" >&2 + docker logs "$DISCRIMINATOR" >&2 || true exit 1 } @@ -87,49 +87,49 @@ logged() { done } -# start_servewaf: run servewaf from the image, with the state files on the +# start_discriminator: run discriminator from the image, with the state files on the # volume, a rate limit of one request a minute and no client looked up, and wait # until it is healthy. -start_servewaf() { - docker run --detach --name "$SERVEWAF" --network "$NETWORK" \ - --volume "$VOLUME:/var/lib/smallwebwaf" \ - --env SMALLWEBWAF_COMMAND=servewaf \ - --env SWWAF_RATE_LIMIT_PER_MINUTE=1 \ - --env SWWAF_LOOKUP_SOURCE=off \ +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 \ "$IMAGE" >/dev/null - wait_for "servewaf's health check did not pass" healthy "$SERVEWAF" + wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" } # start_app: run the app's container, with rproxy in it, and wait until it is # healthy. start_app() { docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \ - --env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \ + --env SWRP_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_servewaf: run servewaf with the state files on a new volume at -# /srv/state, which SWWAF_STATE_DIR_FILE names in a file only the smallwebwaf +# 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 # user can read, as under "Settings given as files" in docs/settings.md, and -# wait until it is healthy. servewaf can write the new volume only once the run +# wait until it is healthy. discriminator can write the new volume only once the run # script has given it to that user. -start_moved_servewaf() { +start_moved_discriminator() { docker volume create "$MOVED_VOLUME" >/dev/null docker volume create "$SETTINGS_VOLUME" >/dev/null docker run --rm --volume "$SETTINGS_VOLUME:/settings" \ --entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir && - chown smallwebwaf:smallwebwaf /settings/state-dir && + chown sww:sww /settings/state-dir && chmod 0400 /settings/state-dir' - docker run --detach --name "$SERVEWAF" --network "$NETWORK" \ + docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \ --volume "$MOVED_VOLUME:/srv/state" \ - --volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \ - --env SMALLWEBWAF_COMMAND=servewaf \ - --env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \ - --env SWWAF_LOOKUP_SOURCE=off \ + --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 \ "$IMAGE" >/dev/null - wait_for "servewaf's health check did not pass" healthy "$SERVEWAF" + wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" } # status_from : print the status a request for path from client, @@ -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, SWWAF_BAN_RESPONSE's +# refused: a request to the container gets 403, SWW_BAN_RESPONSE's # default. refused() { code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ @@ -153,9 +153,9 @@ refused_from() { [ "$(status_from "$1" "$2")" = 403 ] } -# owner_of_state_dir : print who owns /var/lib/smallwebwaf there. +# owner_of_state_dir : print who owns /var/lib/sww there. owner_of_state_dir() { - docker exec "$1" stat --format '%U' /var/lib/smallwebwaf + docker exec "$1" stat --format '%U' /var/lib/sww } main() { @@ -164,91 +164,91 @@ main() { trap 'exit 1' HUP INT TERM docker build --no-cache -t "$IMAGE" . - docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \ + docker build --no-cache --build-arg SWW_IMAGE="$IMAGE" \ -t "$APP_IMAGE" deploy/example-app docker network create "$NETWORK" >/dev/null docker volume create "$VOLUME" >/dev/null - start_servewaf + start_discriminator start_app echo "example-app: the health checks pass" - [ "$(owner_of_state_dir "$SERVEWAF")" = smallwebwaf ] || - fail "servewaf's container did not give the state directory to smallwebwaf" + [ "$(owner_of_state_dir "$DISCRIMINATOR")" = sww ] || + fail "discriminator's container did not give the state directory to sww" [ "$(owner_of_state_dir "$APP")" = root ] || fail "the app's container changed the state directory's owner" - echo "example-app: only servewaf's container changes the state directory's owner" + echo "example-app: only discriminator's container changes the state directory's owner" page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" || fail "no answer on port 8080" [ "$page" = "hello from the example app" ] || fail "port 8080 answered $page" - wait_for "servewaf logged no request it allowed" \ - logged "$SERVEWAF" '"action":"forward"' '"servewaf_line":"report"' + wait_for "discriminator logged no request it allowed" \ + logged "$DISCRIMINATOR" '"action":"forward"' '"discriminator_line":"report"' echo "example-app: rproxy passes a request to the app and its answer back" refused || fail "a second request in a minute was not refused" - wait_for "servewaf logged no ban" logged "$SERVEWAF" '"action":"rate_limited"' + wait_for "discriminator logged no ban" logged "$DISCRIMINATOR" '"action":"rate_limited"' echo "example-app: a second request in a minute bans the client" refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused" - wait_for "servewaf logged no ban for the probe" \ - logged "$SERVEWAF" '"action":"banned"' '"rule_ids":["env-file"]' + wait_for "discriminator logged no ban for the probe" \ + logged "$DISCRIMINATOR" '"action":"banned"' '"rule_ids":["env-file"]' refused_from 203.0.113.9 / || fail "the client of the probe was let through" wait_for "the client's next request did not make its ban permanent" \ - logged "$SERVEWAF" '"ban_expires":"permanent"' + logged "$DISCRIMINATOR" '"ban_expires":"permanent"' echo "example-app: a probe for /.env bans the client, its next request for good" - docker exec "$SERVEWAF" sv stop smallwebwaf >/dev/null || - fail "sv stop smallwebwaf failed in servewaf's container" - wait_for "servewaf did not stop in order" logged "$SERVEWAF" '"msg":"stopped"' + docker exec "$DISCRIMINATOR" sv stop sww >/dev/null || + fail "sv stop sww failed in discriminator's container" + wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"' [ "$(status_from 203.0.113.9 /)" = 200 ] || - fail "with servewaf stopped, the app did not answer through rproxy" + fail "with discriminator stopped, the app did not answer through rproxy" wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \ logged "$APP" '"default_disposition":true' - docker exec "$APP" /usr/local/bin/smallwebwaf healthcheck || - fail "with servewaf stopped, the app's health check failed" - echo "example-app: with servewaf stopped, the app answers through rproxy" + docker exec "$APP" /usr/local/bin/sww healthcheck || + fail "with discriminator stopped, the app's health check failed" + echo "example-app: with discriminator stopped, the app answers through rproxy" - docker exec "$SERVEWAF" sv start smallwebwaf >/dev/null || - fail "sv start smallwebwaf failed in servewaf's container" - wait_for "once servewaf had started again, the ban did not apply" \ + docker exec "$DISCRIMINATOR" sv start sww >/dev/null || + fail "sv start sww failed in discriminator's container" + wait_for "once discriminator had started again, the ban did not apply" \ refused_from 203.0.113.9 / - echo "example-app: once servewaf has started again, bans apply again" + echo "example-app: once discriminator has started again, bans apply again" - docker exec "$APP" sv stop smallwebwaf >/dev/null || - fail "sv stop smallwebwaf failed in the app's container" + docker exec "$APP" sv stop sww >/dev/null || + fail "sv stop sww failed in the app's container" wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"' echo "example-app: sv stop stops rproxy in order" - for container in "$APP" "$SERVEWAF"; do + for container in "$APP" "$DISCRIMINATOR"; do docker stop "$container" >/dev/null status="$(docker inspect --format '{{.State.ExitCode}}' "$container")" [ "$status" = 0 ] || fail "docker stop left exit status $status" done echo "example-app: docker stop stops each container in order" - docker rm "$APP" "$SERVEWAF" >/dev/null - start_servewaf + docker rm "$APP" "$DISCRIMINATOR" >/dev/null + start_discriminator start_app - refused || fail "the new servewaf container let the banned client through" - wait_for "servewaf logged no request refused under the ban" \ - logged "$SERVEWAF" '"action":"banned"' - echo "example-app: a new servewaf container on the same volume keeps the ban" + refused || fail "the new discriminator container let the banned client through" + wait_for "discriminator logged no request refused under the ban" \ + logged "$DISCRIMINATOR" '"action":"banned"' + echo "example-app: a new discriminator container on the same volume keeps the ban" - docker rm --force "$SERVEWAF" >/dev/null - start_moved_servewaf + docker rm --force "$DISCRIMINATOR" >/dev/null + start_moved_discriminator echo "example-app: the run script gives the state files' directory named" \ - "by SWWAF_STATE_DIR_FILE to the smallwebwaf user" + "by SWW_STATE_DIR_FILE to the sww user" - docker rm --force "$SERVEWAF" >/dev/null - docker run --detach --name "$SERVEWAF" --network "$NETWORK" \ - --env SMALLWEBWAF_COMMAND=servewaf \ - --env SWWAF_STATE_DIR=/srv/missing \ - --env SWWAF_LOOKUP_SOURCE=off \ + 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 \ "$IMAGE" >/dev/null - wait_for "servewaf did not report the missing state directory" \ - logged "$SERVEWAF" "SWWAF_STATE_DIR cannot be written" - echo "example-app: servewaf reports a state directory that does not exist" + wait_for "discriminator did not report the missing state directory" \ + logged "$DISCRIMINATOR" "SWW_STATE_DIR cannot be written" + echo "example-app: discriminator reports a state directory that does not exist" } main "$@" diff --git a/script/run b/script/run index 58313e8..5c00807 100755 --- a/script/run +++ b/script/run @@ -1,50 +1,50 @@ #!/bin/sh -# script/run: build bin/smallwebwaf with script/build and run servewaf and +# 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. -# servewaf listens on port 8090 unless SWWAF_LISTEN_ADDR says otherwise, and +# discriminator listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and # rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless -# SWWAF_STATE_DIR is set, the state files go in bin/state, beside the binary, -# and unless SWWAF_RULES_DIR is set, the rule files are those of share/rules.d, +# 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, # which the image ships. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" -SERVEWAF="" +DISCRIMINATOR="" RPROXY="" -# stop_both: stop what runs, and wait for it, so that servewaf writes its state +# stop_both: stop what runs, and wait for it, so that discriminator writes its state # files. stop_both() { trap '' HUP INT TERM - kill $SERVEWAF $RPROXY 2>/dev/null || true + kill $DISCRIMINATOR $RPROXY 2>/dev/null || true wait || true } main() { "$SCRIPT_DIR/build" - if [ -z "${SWWAF_STATE_DIR+set}" ]; then - SWWAF_STATE_DIR="$ROOT/bin/state" - export SWWAF_STATE_DIR - mkdir -p "$SWWAF_STATE_DIR" + if [ -z "${SWW_STATE_DIR+set}" ]; then + SWW_STATE_DIR="$ROOT/bin/state" + export SWW_STATE_DIR + mkdir -p "$SWW_STATE_DIR" fi - if [ -z "${SWWAF_RULES_DIR+set}" ]; then - SWWAF_RULES_DIR="$ROOT/share/rules.d" - export SWWAF_RULES_DIR + if [ -z "${SWW_RULES_DIR+set}" ]; then + SWW_RULES_DIR="$ROOT/share/rules.d" + export SWW_RULES_DIR fi - SWWAF_LISTEN_ADDR="${SWWAF_LISTEN_ADDR:-:8090}" - SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWWAF_LISTEN_ADDR##*:}}" - export SWWAF_LISTEN_ADDR SWRP_WAF_BACKEND + 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 trap stop_both EXIT trap 'exit 1' HUP INT TERM - "$ROOT/bin/smallwebwaf" servewaf & - SERVEWAF=$! - "$ROOT/bin/smallwebwaf" rproxy & + "$ROOT/bin/sww" discriminator & + DISCRIMINATOR=$! + "$ROOT/bin/sww" rproxy & RPROXY=$! wait "$RPROXY" } diff --git a/share/smallwebwaf.run b/share/smallwebwaf.run deleted file mode 100755 index d4be9d4..0000000 --- a/share/smallwebwaf.run +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# runit's run script for smallwebwaf, run again whenever smallwebwaf -# exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the -# command to run: rproxy when it is unset or empty, or servewaf. With any -# other value the script starts nothing and stops with a message naming it. -# exec, so that the signal `sv stop` sends reaches smallwebwaf itself. -main() { - sleep 1 - local command="${SMALLWEBWAF_COMMAND:-rproxy}" - case "$command" in - rproxy) ;; - servewaf) give_state_dir_to_smallwebwaf ;; - *) - printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \ - "$command" >&2 - exit 1 - ;; - esac - exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command" -} - -# give_state_dir_to_smallwebwaf gives the state directory and every file in -# it to the smallwebwaf 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 SWWAF_STATE_DIR, in either form, cannot be read or is not the -# absolute path of a directory, no owner is changed, and servewaf 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: servewaf -# reports a directory it cannot write. -give_state_dir_to_smallwebwaf() { - local dir - if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then - chown -R smallwebwaf:smallwebwaf "$dir" || true - fi -} - -# state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset, -# or what is in the file SWWAF_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 SWWAF_STATE_DIR_FILE ]]; then - printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/smallwebwaf}" - elif [[ ! -v SWWAF_STATE_DIR ]]; then - cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null - else - return 1 - fi -} - -main "$@" diff --git a/share/sww.run b/share/sww.run new file mode 100755 index 0000000..5b31932 --- /dev/null +++ b/share/sww.run @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# runit's run script for sww, run again whenever sww +# exits; the wait spaces out the restarts. SWW_COMMAND names the +# command to run: rproxy when it is unset or empty, or discriminator. With any +# other value the script starts nothing and stops with a message naming it. +# exec, so that the signal `sv stop` sends reaches sww itself. +main() { + sleep 1 + local command="${SWW_COMMAND:-rproxy}" + case "$command" in + rproxy) ;; + discriminator) give_state_dir_to_sww ;; + *) + printf 'SWW_COMMAND: "%s" is not rproxy or discriminator\n' \ + "$command" >&2 + exit 1 + ;; + esac + exec chpst -u sww:sww /usr/local/bin/sww "$command" +} + +# 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 +# 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 +# reports a directory it cannot write. +give_state_dir_to_sww() { + local dir + if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then + chown -R sww:sww "$dir" || true + 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 +# 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 + else + return 1 + fi +} + +main "$@"