Author SHA1 Message Date
sneak d8f417b00b Rename the program to sww and its deciding daemon to discriminator
check / check (push) Successful in 5m33s
The program is now sww, built from cmd/sww, with two modes: sww rproxy and
sww discriminator. servewaf becomes discriminator everywhere it names that
daemon: the package (internal/discriminator), its types, functions, files, the
discriminator_line log field, and its docs and tests.

smallwebwaf is left only where sneak's directive allows it: the documents'
product name, the git.eeqj.de/sneak/smallwebwaf URLs, and the Go module path
sneak.berlin/go/smallwebwaf. Everywhere else a name containing it takes the sww
form: the package (internal/sww), metric names (sww_*), the X-Sww-* headers,
the /_sww/ admin endpoints, the SWW_COMMAND setting, the OS user, and the state
and rules directories.

No logic changes. make check passes.

Model: deepseek-v4-pro
2026-11-07 04:19:17 +00:00
97 changed files with 1945 additions and 1912 deletions

No files matched your search

+12 -12
View File
@@ -87,7 +87,7 @@ COPY . .
# .git present, a version that is still empty, dev or unknown fails the # .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 # 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 # 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. # directory.
ARG VERSION ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
fi; \ fi; \
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \ CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -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 # 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 # 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 . -o /usr/local/bin/runsvinit .
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless # 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. # It is the last stage, so a plain `docker build .` builds it.
# #
# ubuntu 26.04, 2026-09-27 # 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 ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit 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 # 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. # 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 \ && useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \ --gid sww --no-create-home --shell /usr/sbin/nologin \
smallwebwaf sww
# The state files' directory, SWWAF_STATE_DIR by default, where a volume # The state files' directory, SWWAF_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the # is mounted to keep them across deploys. The run script gives it to the
# smallwebwaf user at each start of servewaf. # sww user at each start of discriminator.
RUN mkdir /var/lib/smallwebwaf RUN mkdir /var/lib/sww
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile # The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it. # 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 # runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
# looks too. # looks too.
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run COPY --chmod=755 share/sww.run /etc/service/sww/run
EXPOSE 8080 EXPOSE 8080
@@ -194,6 +194,6 @@ EXPOSE 8080
# check runs every second from the start until it first passes, for up # check runs every second from the start until it first passes, for up
# to a minute, and every 30 seconds after that. # to a minute, and every 30 seconds after that.
HEALTHCHECK --start-period=1m --start-interval=1s \ HEALTHCHECK --start-period=1m --start-interval=1s \
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"] CMD ["/usr/local/bin/sww", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/runsvinit"] ENTRYPOINT ["/usr/local/bin/runsvinit"]
+115 -116
View File
@@ -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 refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
one image, with two commands that do this together: one image, with two commands that do this together:
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik - `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
routes to it. It asks `servewaf` about each request, passes the allowed ones it. It asks `discriminator` about each request, passes the allowed ones to the
to the app, and passes the app's answers back. It keeps nothing and never app, and passes the app's answers back. It keeps nothing and never touches
touches disk. disk.
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the - `sww discriminator` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `servewaf` serves many sites: a client counters and each client's history. One `discriminator` serves many sites: a
banned through one site is refused on all of them, and its requests on all of client banned through one site is refused on all of them, and its requests on
them count toward one set of limits. all of them count toward one set of limits.
The site keeps answering while `servewaf` is down, slow or restarting: without The site keeps answering while `discriminator` is down, slow or restarting:
an answer in time, `rproxy` passes the request to the app. without an answer in time, `rproxy` passes the request to the app.
It protects against: 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. 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 image runs `rproxy` by default. To run `discriminator` from the same image,
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check 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 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 the app accepts connections, whether or not `discriminator` answers; in
container, once `servewaf` answers. `discriminator`'s container, once `discriminator` answers.
### The usual setup: three containers ### The usual setup: three containers
@@ -91,24 +91,24 @@ The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND` - `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `servewaf` and `SWRP_UPSTREAM_URL` names the app; names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at - `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
`/var/lib/smallwebwaf` for its state files. `/var/lib/sww` for its state files.
Point traefik's labels at port 8080 of `rproxy` Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `servewaf`. Anything on that network can reach the app around of the app or of `discriminator`. Anything on that network can reach the app
`rproxy`, and can ask `servewaf` questions, so the network holds only what you around `rproxy`, and can ask `discriminator` questions, so the network holds
trust. Each further app adds its own container and its own `rproxy`, whose only what you trust. Each further app adds its own container and its own
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" example below `rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
shows this shape. setup" example below shows this shape.
### `rproxy` in the app's container ### `rproxy` in the app's container
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy` 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 runs beside the app in one container. `discriminator` still runs in a container
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
digest, and adds: by digest, and adds:
- the app's binary; - the app's binary;
- the packages the app needs, from the nixpkgs in the image; - 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 - A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked. setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer. - A request to the site through traefik gets the app's answer.
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines `https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
about the request have `"action":"forward"`. the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with - To see a refusal without banning yourself, run `discriminator` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. `SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and `discriminator`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes. "Clients and bans" below describes.
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on - With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
`servewaf`'s network: `discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`. `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 - `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints"). the whole exchange (see "Entrypoints").
@@ -185,8 +185,8 @@ does.
Every setting is an environment variable, read once at start; changing one means Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and 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 `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has
default, chosen for a service on the open internet. A setting that needs an 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. 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 - Any `SWWAF_` setting can be given as a file instead, for secrets and long
@@ -216,23 +216,23 @@ Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. | | `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_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_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_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_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 `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | | `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_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_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_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`. | | `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_LISTEN_ADDR` | `:8080` | Where `discriminator` 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_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | | `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_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_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_MAX_BANS` | `5000` | The most bans `discriminator` 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_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_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_DENY_NETS` | empty | Netblocks whose clients are refused. |
@@ -263,7 +263,7 @@ Every setting the code reads, with its default:
| `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_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_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<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | | `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` 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_DIR` | `/etc/sww/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_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_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_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. |
@@ -285,18 +285,18 @@ Every setting the code reads, with its default:
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. | | `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `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_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_DIR` | `/var/lib/sww` | 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_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_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_LEVEL` | `info` | The least severe of `discriminator`'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_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_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. |
| `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_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_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_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_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_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_TOKEN` | unset | The bearer token for `/_sww/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_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_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 <token>`. | | `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
@@ -343,8 +343,8 @@ own, where each reaches the others by service name. Each example publishes
### Smallest setup ### Smallest setup
One app, `rproxy` in front of it, and `servewaf` with a volume for its state One app, `rproxy` in front of it, and `discriminator` with a volume for its
files, every setting at its default. state files, every setting at its default.
```yaml ```yaml
services: services:
@@ -355,20 +355,20 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWRP_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
### Custom per-address limits ### Custom per-address limits
@@ -391,15 +391,15 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWRP_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
# Monitoring skips every check. # Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10 SWWAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited. # The office and the automation host are never rate limited.
@@ -419,18 +419,18 @@ services:
SWWAF_BAN_RESPONSE: "429" SWWAF_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m SWWAF_LIMIT_BAN_DURATION: 30m
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
### `rproxy` beside the app ### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container. 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 `app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `servewaf` is down, container". `rproxy` answers `503` to every request while `discriminator` is
and `servewaf`: down, and `discriminator`:
- observes before it enforces; - observes before it enforces;
- looks addresses up in a local database rather than GeoJS; - looks addresses up in a local database rather than GeoJS;
@@ -443,17 +443,17 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside. # A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app build: ./app
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
# Answer 503 rather than pass a request on while servewaf is down. # Answer 503 rather than pass a request on while discriminator is down.
SWRP_DEFAULT_DISPOSITION: deny SWRP_DEFAULT_DISPOSITION: deny
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
SWWAF_INSTANCE_NAME: fsn1/servewaf SWWAF_INSTANCE_NAME: fsn1/discriminator
# Refuse nothing yet; log what enforce mode would have done. # Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe SWWAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS. # Look addresses up in a local IPinfo Lite database, never GeoJS.
@@ -461,28 +461,28 @@ services:
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file. # Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro - /srv/sww/tokens:/etc/sww/tokens:ro
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
The database is mounted by its directory, not by file, so that a replaced 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, database is seen. A token file belongs to uid 65532, the `sww` user, with mode
with mode `0400`. This one holds an access token the ntfy server issued, which `0400`. This one holds an access token the ntfy server issued, which
`ntfy token add <user>` prints there: `ntfy token add <user>` prints there:
```sh ```sh
mkdir -p /srv/smallwebwaf/tokens mkdir -p /srv/sww/tokens
# <token> is the access token that ntfy printed. # <token> is the access token that ntfy printed.
echo '<token>' > /srv/smallwebwaf/tokens/ntfy echo '<token>' > /srv/sww/tokens/ntfy
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy chown 65532:65532 /srv/sww/tokens/ntfy
chmod 0400 /srv/smallwebwaf/tokens/ntfy chmod 0400 /srv/sww/tokens/ntfy
``` ```
## Design ## Design
@@ -491,18 +491,18 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request ### What happens to a request
A request goes through these steps, in this order. Once `servewaf` refuses a A request goes through these steps, in this order. Once `discriminator` refuses
request, the steps after that are skipped, apart from the report, and nothing of a request, the steps after that are skipped, apart from the report, and nothing
the request reaches the app. of the request reaches the app.
`rproxy` takes the first two: `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 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 from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
visitor sent it, with the first 64 KiB of its body. 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, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
4. The bans. 4. The bans.
@@ -515,15 +515,15 @@ the request reaches the app.
10. The trap paths. 10. The trap paths.
11. The rule files. 11. The rule files.
12. The header limits, and then the Core Rule Set. 12. The header limits, and then the Core Rule Set.
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees 13. A path under `/_sww/` is answered `404`, so that the app never sees that
that prefix. 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. 14. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `servewaf` a report with the status and the 15. After the answer, `rproxy` sends `discriminator` a report with the status
bytes each way. For an allowed request, `servewaf` counts the bytes toward and the bytes each way. For an allowed request, `discriminator` counts the
the byte limits and adds the request to the client's history. 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 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 Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
@@ -558,17 +558,17 @@ and the status for a size or time limit, such as `413` for a body too large.
Git Credential Manager. No setting undoes them; "Attack detection" in Git Credential Manager. No setting undoes them; "Attack detection" in
[`SPEC.md`](SPEC.md) lists them. [`SPEC.md`](SPEC.md) lists them.
- Rule files are plain text, one rule to a line, edited by hand and read again - 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)). sends (see [`docs/rules.md`](docs/rules.md)).
### State files ### State files
`servewaf` keeps everything in memory, and the five JSON files in `discriminator` keeps everything in memory, and the five JSON files in
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, `SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file `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 renamed over the real one, so a crash leaves a complete file. `discriminator`
a file edited on disk and takes the edit in; an edit that does not parse is set notices a file edited on disk and takes the edit in; an edit that does not parse
aside as `<name>.bad`, and never stops the running process (see is set aside as `<name>.bad`, and never stops the running process (see
[`docs/state-files.md`](docs/state-files.md)). [`docs/state-files.md`](docs/state-files.md)).
### The image and its processes ### The image and its processes
@@ -577,30 +577,30 @@ aside as `<name>.bad`, and never stops the running process (see
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the `Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)). 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 - runit, a service supervisor, runs `smallwebwaf` as the `sww` user (uid and
and gid 65532) and starts it again when it exits. The `run` script of 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, `discriminator` gives the state directory to the `sww` user at each start, so
so a volume needs no change of owner. a volume needs no change of owner.
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints, - `rproxy` and `discriminator` use no token between them. `discriminator`'s own
under `/_smallwebwaf/`, are for your admin tools and your metrics collector, endpoints, under `/_sww/`, are for your admin tools and your metrics
at its own address on the private network; nothing routes the internet there. 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` - `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 in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them. `SIGTERM` for that to help them.
- Only faults found at start stop a process: an invalid setting, a rule file or - 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 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, 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 ### Packages
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. - `cmd/sww`: the binary, which only calls `internal/sww`.
- `internal/smallwebwaf`: the process, as the command it is given, and the - `internal/sww`: the process, as the command it is given, and the health check.
health check.
- `internal/config`: reads the settings, the one place they are read. - `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app. - `internal/rproxy`: the reverse proxy in front of one app.
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers - `internal/discriminator`: `discriminator`'s server, which runs the checks and
its own endpoints. answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`, - `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and `internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/alerts`: one check or source each. `internal/alerts`: one check or source each.
@@ -664,27 +664,26 @@ on the host.
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
- `script/install-precommit`: installs that hook. `make hooks`. - `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/smallwebwaf` with the Go on your host. - `script/build`: builds `bin/sww` with the Go on your host. `make build`.
`make build`. - `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
host, with the settings in your environment. `make run`. Unless your settings host, with the settings in your environment. `make run`. Unless your settings
say otherwise: say otherwise:
- `rproxy` listens on port 8080, in front of an app at - `rproxy` listens on port 8080, in front of an app at
`http://127.0.0.1:8081`; `http://127.0.0.1:8081`;
- `servewaf` listens on port 8090; - `discriminator` listens on port 8090;
- its state files go in `bin/state`; - its state files go in `bin/state`;
- its rule files are those of `share/rules.d`. - its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app - `script/example-app`: builds the image and the example app
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and (`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 `discriminator` in another, and checks them. Then it removes the containers,
network, the volumes and the images. Needs network access. `make example-app`. the network, the volumes and the images. Needs network access.
It checks: `make example-app`. It checks:
- the health checks; - the health checks;
- a request passed to the app; - a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent; - 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; - that only `discriminator`'s container changes the state directory's owner;
- with `servewaf` stopped, the app still answering through `rproxy`; - with `discriminator` stopped, the app still answering through `rproxy`;
- with `servewaf` started again, bans applying again; - with `discriminator` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`; - orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume; - bans surviving a new container on the same volume;
- a setting given as a file. - a setting given as a file.
@@ -703,7 +702,7 @@ on the host.
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes. - [`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/rules.md`](docs/rules.md): the rule file format and the default file.
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - [`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/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 - [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
gets. gets.
+321 -323
View File
File diff suppressed because it is too large. Load diff
-20
View File
@@ -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))
}
+20
View File
@@ -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))
}
+25 -25
View File
@@ -1,60 +1,60 @@
# Admin endpoints # Admin endpoints
The endpoints under `/_smallwebwaf/`, what each takes and what each answers. The endpoints under `/_sww/`, what each takes and what each answers.
`smallwebwaf servewaf` answers them at its own address, on the private network. `sww discriminator` answers them at its own address, on the private network.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone. - `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see - `GET /_sww/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
[`metrics.md`](metrics.md)). [`metrics.md`](metrics.md)).
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json` - `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of - `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock `duration` and, if you like, `reason`. `netblock` is a netblock such as
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock `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 (`SWWAF_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 as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
wrong: wrong:
- one that is not such an object, or has another field; - one that is not such an object, or has another field;
- one longer than 4 KiB; - one longer than 4 KiB;
- one that names an IPv4-mapped netblock, or an address with a zone. - one that names an IPv4-mapped netblock, or an address with a zone.
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the - `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
address `<client>`. It answers `404` when none is active. `<client>`. It answers `404` when none is active.
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under - `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
`client`, its counters and history as `clients.json` holds them, or `null` `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 when `clients.json` does not hold it. Under `bans`, every ban covering the
address, with its notes. address, with its notes.
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It - `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
answers `204`, or `400` to a body that is no report (see "The report" in `204`, or `400` to a body that is no report (see "The report" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
No check runs on these requests. A missing or wrong token is answered `401`. An 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. 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. `sww rproxy` answers only the health check of these, at its own address. It asks
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that `discriminator` about any other request under `/_sww/`. One that passes the
passes the checks is answered `404`, so that the app never sees that prefix (see checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
"`rproxy`" in [`SPEC.md`](../SPEC.md)). in [`SPEC.md`](../SPEC.md)).
## Examples ## Examples
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
`servewaf:8080` on its network: as `discriminator:8080` on its network:
```sh ```sh
# Every ban. # 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. # Ban 203.0.113.0/24 for seven days.
curl -H "Authorization: Bearer $TOKEN" \ curl -H "Authorization: Bearer $TOKEN" \
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \ --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. # Lift the bans on 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" -X DELETE \ 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" \ curl -H "Authorization: Bearer $TOKEN" \
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9 http://discriminator:8080/_sww/clients/203.0.113.9
``` ```
+7 -7
View File
@@ -6,7 +6,7 @@ back or dropped.
## Events ## 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 - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request. a request.
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The - `waf_block`: the Core Rule Set scored a request at or over the threshold. The
@@ -34,7 +34,7 @@ on one line:
```json ```json
{ {
"instance": "fsn1/servewaf", "instance": "fsn1/discriminator",
"time": "2026-10-06T12:00:50.123461Z", "time": "2026-10-06T12:00:50.123461Z",
"event": "ban", "event": "ban",
"host": "app.example", "host": "app.example",
@@ -79,9 +79,9 @@ on one line:
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above. one of the events above.
- `host`: the site of the request that raised the alert, since one `servewaf` - `host`: the site of the request that raised the alert, since one
serves many sites. It is left out of `source_failure`, `file_error` and `discriminator` serves many sites. It is left out of `source_failure`,
`summary`. `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`. `source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise - `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
@@ -111,8 +111,8 @@ on one line:
## Slack and ntfy ## Slack and ntfy
Slack and ntfy get a message with a title and a text. The title is the instance 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 and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
line for each of these that the alert has: one line for each of these that the alert has:
- the host, the client, the netblock and the country; - the host, the client, the netblock and the country;
- the file, the source, the error and the mode from `detail`; - the file, the source, the error and the mode from `detail`;
+8 -9
View File
@@ -1,7 +1,7 @@
# What the image holds # What the image holds
The contents of the `smallwebwaf` image, and how each part is pinned. The image 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 "Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
is pinned that way. is pinned that way.
@@ -15,13 +15,12 @@ is pinned that way.
Unpacked, nixpkgs takes about 500 MiB. Unpacked, nixpkgs takes about 500 MiB.
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit` - `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
at a fixed commit hash. at a fixed commit hash.
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532). - The `sww` binary, and the user `sww` (uid and gid 65532).
- The service directory `/etc/service/smallwebwaf`, with the `run` script - The service directory `/etc/service/sww`, with the `run` script
`share/smallwebwaf.run`. `share/sww.run`.
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and - `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
`/etc/smallwebwaf/rules.d` with the default rule file the default rule file `share/rules.d/00-default.rules`.
`share/rules.d/00-default.rules`. - `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
## Pins ## Pins
@@ -33,4 +32,4 @@ is pinned that way.
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit - `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 (`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
build writes. build writes.
- The Go image that builds `smallwebwaf`: by digest. - The Go image that builds `sww`: by digest.
+39 -48
View File
@@ -1,90 +1,81 @@
# The metrics # 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 "Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
keeps the `instance` label as `exported_instance` unless the scrape sets keeps the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics. `honor_labels: true`. `rproxy` has no metrics.
## Traffic ## Traffic
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and - `sww_requests_total`, `sww_request_bytes_total` and
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by `sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
`action`; - `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
- `smallwebwaf_request_duration_seconds` and histograms;
`smallwebwaf_upstream_duration_seconds`, as histograms; - `sww_requests_in_flight`: the questions and requests to its own endpoints that
- `smallwebwaf_requests_in_flight`: the questions and requests to its own `discriminator` is handling.
endpoints that `servewaf` is handling.
## Limits and bans ## Limits and bans
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`, - `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
`bytes` or `refusals`); `refusals`);
- `smallwebwaf_offences_total`, by `kind`; - `sww_offences_total`, by `kind`;
- `smallwebwaf_bans_made_total`, by `cause`; - `sww_bans_made_total`, by `cause`;
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. - `sww_active_bans` and `sww_permanent_bans`.
## Attack detection ## Attack detection
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and - `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
`smallwebwaf_rules_loaded`; - `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
fired.
## Countries and AS numbers ## Countries and AS numbers
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`: For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
- `smallwebwaf_country_requests_total`, - `sww_country_requests_total`, `sww_country_request_bytes_total`,
`smallwebwaf_country_request_bytes_total`, `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
`smallwebwaf_country_response_bytes_total` and `country`;
`smallwebwaf_country_list_refusals_total`, by `country`; - `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and `sww_asn_response_bytes_total`, by `asn`.
`smallwebwaf_asn_response_bytes_total`, by `asn`.
## Lookups ## Lookups
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`; - `sww_geojs_requests_total` and `sww_geojs_failures_total`;
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and - `sww_geojs_unanswered_total`: the requests that needed an answer and went on
went on without it; without it;
- with the file source, - with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and `sww_lookup_database_read_failures_total`.
`smallwebwaf_lookup_database_read_failures_total`.
## Reputation sources ## Reputation sources
By `source`: each list's URL, each zone, and `abuseipdb`: By `source`: each list's URL, each zone, and `abuseipdb`:
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total` - `sww_reputation_hits_total`, `sww_reputation_queries_total` and
and `smallwebwaf_reputation_failures_total`; `sww_reputation_failures_total`;
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list; - `sww_reputation_last_fetch_timestamp_seconds`, for a list;
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB. - `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
## State files ## State files
By `file`: By `file`:
- `smallwebwaf_state_file_writes_total`, - `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
`smallwebwaf_state_file_write_failures_total`, `sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
`smallwebwaf_state_file_last_write_timestamp_seconds` and - `sww_state_file_edits_taken_in_total` and
`smallwebwaf_state_file_size_bytes`; `sww_state_file_edits_set_aside_total`;
- `smallwebwaf_state_file_edits_taken_in_total` and - `sww_tracked_clients`: the clients in the table.
`smallwebwaf_state_file_edits_set_aside_total`;
- `smallwebwaf_tracked_clients`: the clients in the table.
## Remote log, alerts and the process ## Remote log, alerts and the process
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`, - while a syslog server is set, `sww_remote_log_lines_sent_total`,
`smallwebwaf_remote_log_lines_dropped_total` and `sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
`smallwebwaf_remote_log_buffer_depth`; - by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
- by `destination`, `smallwebwaf_alerts_sent_total`, `sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
`smallwebwaf_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics. - Go's own `go_` metrics and the process's `process_` metrics.
## When a request is counted ## When a request is counted
`servewaf` counts a denied request at its decision, and a request to its own `discriminator` counts a denied request at its decision, and a request to its
endpoints when it is answered. It counts an allowed request when its report 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: comes, with the status, bytes and times the report gives. These are not counted:
- a report itself; - a report itself;
+32 -31
View File
@@ -1,32 +1,32 @@
# The request log's fields # The request log's fields
The fields of the JSON objects `rproxy` and `servewaf` write about each request. The fields of the JSON objects `rproxy` and `discriminator` write about each
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
written and how the log is sent to a syslog server. A field that does not apply is written and how the log is sent to a syslog server. A field that does not
to a request is left out. 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 `sww discriminator` writes two lines for each question it answers, joined by
`request_id`. The field `servewaf_line` says which of the two a line is: `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; - `decision`: the line it writes once it has answered the question;
- `report`: the line it writes when `rproxy`'s report comes, with everything - `report`: the line it writes when `rproxy`'s report comes, with everything
known. The repetition is intended. 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. report writes no line but the `report` line of the request it names.
These questions write no line: 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)); [`SPEC.md`](../SPEC.md));
- a question whose body stopped arriving, which gets no answer. - a question whose body stopped arriving, which gets no answer.
A `report` line: 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 ### Every line
@@ -41,7 +41,7 @@ Every one of these lines has:
### Standard web log fields ### 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` milliseconds, in UTC. The `report` line gives the same time as the `decision`
line. line.
- `instance`: `SWWAF_INSTANCE_NAME`. - `instance`: `SWWAF_INSTANCE_NAME`.
@@ -52,8 +52,9 @@ Every one of these lines has:
- `host`: the site the visitor asked for. - `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`. - `protocol`: the question's own, `HTTP/1.1`.
- `status`: - `status`:
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the - on the `decision` line, what `discriminator` answered: `200` for `allow`,
status of `deny`, or `0` for `close`, as the visitor is then sent nothing; 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. - on the `report` line, what the visitor got, `0` if nothing.
- `request_bytes`: - `request_bytes`:
- on the `decision` line, how much of the question's body the Core Rule Set - 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, - on the `report` line, the bytes of the visitor's body that `rproxy` read,
with what a WebSocket carried from the visitor. with what a WebSocket carried from the visitor.
- `response_bytes`: - `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 - on the `report` line, the bytes of the answer's body, with what a
WebSocket carried from the app. WebSocket carried from the app.
- `referer` and `user_agent`. - `referer` and `user_agent`.
@@ -78,7 +79,7 @@ Every one of these lines has:
- lookups are off; - lookups are off;
- the client is not looked up; - the client is not looked up;
- the source cannot place the client; - 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_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced. - `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name - `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
@@ -88,7 +89,7 @@ Every one of these lines has:
### Response detail ### 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. - `aborted`: true when `rproxy` went away before the answer.
### Decision ### Decision
@@ -102,8 +103,8 @@ Every one of these lines has:
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
refused by that check; refused by that check;
- `too_large`: over a header limit; - `too_large`: over a header limit;
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a - `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
path under `/_smallwebwaf/`. a path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `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 request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
@@ -147,8 +148,8 @@ Every one of these lines has:
All in milliseconds: All in milliseconds:
- `duration_total`: - `duration_total`:
- on the `decision` line, from `servewaf` having read the question's body to - on the `decision` line, from `discriminator` having read the question's
the line; body to the line;
- on the `report` line, from `rproxy` having read the request's headers to - on the `report` line, from `rproxy` having read the request's headers to
the end of the answer. the end of the answer.
- `duration_checks`: to the end of the checks; - `duration_checks`: to the end of the checks;
@@ -158,8 +159,8 @@ All in milliseconds:
### Requests to its own endpoints ### Requests to its own endpoints
The line of a request to `servewaf`'s own endpoints has these fields, with these The line of a request to `discriminator`'s own endpoints has these fields, with
meanings where they differ: these meanings where they differ:
- `client_ip` and `peer_ip`: the request's TCP peer. - `client_ip` and `peer_ip`: the request's TCP peer.
- `request_id`: a new random one of 26 letters and digits. - `request_id`: a new random one of 26 letters and digits.
@@ -171,8 +172,8 @@ meanings where they differ:
## `rproxy`'s line ## `rproxy`'s line
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins `sww rproxy` writes one short line for each request. `request_id` joins it to
it to `servewaf`'s two. It has these fields and no others: `discriminator`'s two. It has these fields and no others:
- `type`: `request`. - `type`: `request`.
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC. - `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. carried from the app.
- `duration_request`: milliseconds from the request's headers being read until - `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 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. - `duration_response`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `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 is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
out when: left out when:
- `servewaf` was not asked, as for the health check; - `discriminator` was not asked, as for the health check;
- the visitor went away while `servewaf` was being asked; - the visitor went away while `discriminator` was being asked;
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`; - `discriminator`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`. - `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave - `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out. `disposition`, and otherwise left out.
+8 -7
View File
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
## The default file ## The default file
The image ships one file, `share/rules.d/00-default.rules`, installed as 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 # 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 ## A file of your own
`servewaf` reads the rule files in its own container. Rules of your own go in a `discriminator` reads the rule files in its own container. Rules of your own go
file named to sort after the default, such as `50-site.rules`: in a file named to sort after the default, such as `50-site.rules`:
``` ```
# 50-site.rules: WordPress probes, which this site never serves # 50-site.rules: WordPress probes, which this site never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
``` ```
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from, A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
adds it: from, adds it:
```dockerfile ```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`.
+39 -38
View File
@@ -4,8 +4,8 @@ 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 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. 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 The settings that start with `SWWAF_` are `discriminator`'s. Those that start
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND` with `SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
chooses between the two (see "The image's command" below). chooses between the two (see "The image's command" below).
## Forms of values ## Forms of values
@@ -28,12 +28,12 @@ chooses between the two (see "The image's command" below).
## Core ## Core
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a - `SWWAF_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and
port, such as `:9000`. Leave the host out (see "Ports" in a port, such as `:9000`. Leave the host out (see "Ports" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance` - `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 name in each line `discriminator` logs, and in each alert and metric, such as
`fsn1/servewaf`. Valid UTF-8. No control character while `fsn1/discriminator`. Valid UTF-8. No control character while
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a `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 space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
not. not.
@@ -42,20 +42,20 @@ chooses between the two (see "The image's command" below).
client's group, from 32 to 128. client's group, from 32 to 128.
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole - `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero. number above zero.
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps, - `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
a whole number above zero. keeps, a whole number above zero.
## Tokens ## Tokens
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
`/_smallwebwaf/clients/<ip>`. At least 32 characters. `/_sww/clients/<ip>`. At least 32 characters.
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`. - `SWWAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
At least 32 characters. 32 characters.
## State files ## State files
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state - `SWWAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state files,
files, an absolute path that exists and can be written. an absolute path that exists and can be written.
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be - `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration. written after a ban is made, lifted or made permanent. A duration.
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
@@ -63,7 +63,7 @@ chooses between the two (see "The image's command" below).
## Logging ## Logging
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own - `SWWAF_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
messages that are written: `debug`, `info`, `warn` or `error`. messages that are written: `debug`, `info`, `warn` or `error`.
- `SWWAF_LOG_REQUEST_HEADERS` (default - `SWWAF_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the `accept,accept-language,accept-encoding,content-type,origin,range`): the
@@ -170,8 +170,8 @@ chooses between the two (see "The image's command" below).
## Attack detection ## Attack detection
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the - `SWWAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
rule files. files.
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file. - `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as - `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
@@ -310,9 +310,9 @@ Then mount its directory into the container read-only, and name the file:
```sh ```sh
docker run \ docker run \
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \ -v /srv/app/tokens:/etc/sww/tokens:ro \
-e SMALLWEBWAF_COMMAND=servewaf \ -e SWW_COMMAND=discriminator \
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \ -e SWWAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
... ...
``` ```
@@ -325,41 +325,42 @@ A few limits are fixed rather than settings:
- at most 1,000 DNS blocklist queries are under way at once; - at most 1,000 DNS blocklist queries are under way at once;
- at most 20,000 anomaly counters are kept; - at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts; - each alert destination's queue holds at most 1,000 alerts;
- `servewaf` holds at most 10,000 answered requests awaiting their reports; - `discriminator` holds at most 10,000 answered requests awaiting their reports;
- `servewaf` refuses a request with more than 50 cookies or more than 100 header - `discriminator` refuses a request with more than 50 cookies or more than 100
fields (see "Answering in time" under "`servewaf`" in header fields (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)); [`SPEC.md`](../SPEC.md));
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a - `rproxy` takes a request line and headers of at most 32 KiB, and closes a
kept-open connection idle for 120 seconds; kept-open connection idle for 120 seconds;
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers - `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
`502` to a longer one. 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. `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 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 waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
asked in the background (see "Answering in time" under "`servewaf`" in asked in the background (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
## `rproxy` ## `rproxy`
`smallwebwaf rproxy` reads the settings below and no others. They are read once `sww rproxy` reads the settings below and no others. They are read once at
at start, and an invalid value stops the start. None can be given as a file, start, and an invalid value stops the start. None can be given as a file, since
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
what they do. they do.
A time here is a whole number of milliseconds above zero, such as `2000`. 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` `https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it. speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s - `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
answer, to a question and to a report. A time; never `off`. `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 - `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 `discriminator` cannot be connected to, does not answer at all, or does not
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
`503`. It does not apply to an answer `servewaf` gives, such as an error. 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 - `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`. `off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or - `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
@@ -383,8 +384,8 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
## The image's command ## The image's command
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what `SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
its health check checks: `rproxy` or `servewaf` (see "Deployment" in check checks: `rproxy` or `discriminator` (see "Deployment" in
[`SPEC.md`](../SPEC.md)). It cannot be given as a file. [`SPEC.md`](../SPEC.md)). It cannot be given as a file.
## Where earlier settings went ## Where earlier settings went
+1 -1
View File
@@ -7,7 +7,7 @@ Each has a top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
those `servewaf` made. Per entry: those `discriminator` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban); - `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`; - `cause`: `attack`, `limit`, `admin` or `crowdsec`;
+6 -6
View File
@@ -36,9 +36,9 @@ import (
// The events an alert is for, as SWWAF_ALERT_EVENTS names them. // The events an alert is for, as SWWAF_ALERT_EVENTS names them.
const ( const (
// EventBan is a ban smallwebwaf made. // EventBan is a ban sww made.
EventBan = "ban" 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. // made permanent.
EventPermanentBan = "permanent_ban" EventPermanentBan = "permanent_ban"
// EventAnomaly is a count of requests or bytes over an anomaly // EventAnomaly is a count of requests or bytes over an anomaly
@@ -52,11 +52,11 @@ const (
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score // CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
// is a hit. // is a hit.
EventReputationHit = "reputation_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 // 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. // failing or refused, or the day's AbuseIPDB checks used up.
EventSourceFailure = "source_failure" 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 // runs that does not parse, a replacement of the lookup database that
// cannot be read, or a state file that cannot be written. // cannot be read, or a state file that cannot be written.
EventFileError = "file_error" EventFileError = "file_error"
@@ -151,7 +151,7 @@ type Params struct {
// Alert is one alert, as the webhook is sent it and alerts.json holds it, // 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 // 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 // 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. // many sites, and "" otherwise.
// //
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case //nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
@@ -683,7 +683,7 @@ func (d *destination) run(ctx context.Context) {
d.processLog.Warn("gave up an alert "+d.setting+" refused", d.processLog.Warn("gave up an alert "+d.setting+" refused",
"event", alert.Event, "error", err.Error()) "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) d.failed.Add(1)
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
+4 -4
View File
@@ -34,9 +34,9 @@ const (
// webhookURL is where the alerts are posted, slackURL the Slack // webhookURL is where the alerts are posted, slackURL the Slack
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy // incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
// token of the tests that set one. // 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" slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
ntfyURL = "https://ntfy.example/smallwebwaf-alerts" ntfyURL = "https://ntfy.example/sww-alerts"
ntfyToken = "tk_0123456789abcdefghijklmnopq" ntfyToken = "tk_0123456789abcdefghijklmnopq"
// instance is the instance name every alert gives. // instance is the instance name every alert gives.
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: "a rule file has an error, and the rules stay as they were", Reason: "a rule file has an error, and the rules stay as they were",
Detail: map[string]any{ 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", "fsn1app1/blog: file_error", "high warning",
"a rule file has an error, and the rules stay as they were\n" + "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",
}, },
} }
} }
+4 -4
View File
@@ -33,7 +33,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
third := netip.MustParsePrefix("203.0.113.3/32") third := netip.MustParsePrefix("203.0.113.3/32")
// Seen longest ago, a netblock with two of an admin's bans alone, and // 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. // ban counted toward MaxBans.
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin}, {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}) 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{}) ledger.BanForLimit(second, midnight(), bans.Notes{})
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}) 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() t.Parallel()
ledger := bans.New(defaultRules()) 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) 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. // start, and adds one without a cause: that one alone is made.
kept := made kept := made
kept.Cause = bans.CauseAdmin kept.Cause = bans.CauseAdmin
+7 -7
View File
@@ -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 // 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 // burst, show a clear sign of attack or are listed by the CrowdSec
// decision list, and // decision list, and
@@ -21,14 +21,14 @@ import (
// The causes of bans. // The causes of bans.
const ( const (
// CauseLimit is a ban smallwebwaf made for a broken limit. // CauseLimit is a ban sww made for a broken limit.
CauseLimit = "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" 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. // admin keeps. It is never dropped.
CauseAdmin = "admin" 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. // decision list lists. It ends when CrowdSec's decision does.
CauseCrowdSec = "crowdsec" CauseCrowdSec = "crowdsec"
) )
@@ -70,7 +70,7 @@ type Ban struct {
Expires time.Time Expires time.Time
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec. // Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
Cause string 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 // the rule that matched or the scenario of CrowdSec's decision; for an
// admin's, what the admin wrote. // admin's, what the admin wrote.
Reason string Reason string
@@ -581,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
} }
// LoadEdit is Load for an admin's edit of bans.json, taken in while // 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 // the ledger did not hold, with the same netblock and start, is one the
// admin made, and is counted among the bans made. // admin made, and is counted among the bans made.
func (l *Ledger) LoadEdit(bans []Ban) { func (l *Ledger) LoadEdit(bans []Ban) {
+1 -1
View File
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
t.Parallel() 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. // 1-hour ban added to bans.json over it, with no cause and no notes.
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
nineHours := bans.Ban{ nineHours := bans.Ban{
+26 -26
View File
@@ -1,9 +1,9 @@
// Package config reads smallwebwaf's settings, and is the one place they are // Package config reads sww's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of `smallwebwaf // read. Every setting is an environment variable. The settings of `sww
// servewaf` start with SWWAF_, every one of them has a default, and most can be // discriminator` 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 // 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, // 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. // the image runs.
package config package config
@@ -31,10 +31,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog" "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. // zero is off.
type Config struct { type Config struct {
// ListenAddr is where servewaf listens (SWWAF_LISTEN_ADDR). // ListenAddr is where discriminator listens (SWWAF_LISTEN_ADDR).
ListenAddr string ListenAddr string
// InstanceName is the name every log line and alert gives as instance, // 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 (SWWAF_INSTANCE_NAME),
@@ -199,7 +199,7 @@ type Config struct {
// written (SWWAF_LOG_LEVEL). It holds back no request log line. // written (SWWAF_LOG_LEVEL). It holds back no request log line.
LogLevel slog.Level LogLevel slog.Level
// AdminToken is the bearer token an admin sends for the ban endpoints // AdminToken is the bearer token an admin sends for the ban endpoints
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is // and /_sww/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
// unset and they are off. // unset and they are off.
AdminToken string AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics // MetricsToken is the bearer token a scraper sends for the metrics
@@ -231,7 +231,7 @@ type Config struct {
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute // 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 // that discriminator may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off. // more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64 ErrorBurstThreshold int64
// LogRemoteURL is where every line on stdout is also sent // LogRemoteURL is where every line on stdout is also sent
@@ -327,7 +327,7 @@ const (
// maxParanoiaLevel is the Core Rule Set's highest paranoia level. // maxParanoiaLevel is the Core Rule Set's highest paranoia level.
maxParanoiaLevel = 4 maxParanoiaLevel = 4
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set // 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 // have too (see internal/waf). Switching one off would undo a change
// that no setting undoes. // that no setting undoes.
firstSetupRuleID = 900000 firstSetupRuleID = 900000
@@ -370,8 +370,8 @@ var (
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New("it names the file to look clients up in")
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New( errOver64K = errors.New(
"is more than 64K, the most servewaf gets of a body") "is more than 64K, the most discriminator gets of a body")
errNotCommand = errors.New("is not rproxy or servewaf") errNotCommand = errors.New("is not rproxy or discriminator")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -387,7 +387,7 @@ var (
errNotNamedNetblock = errors.New( errNotNamedNetblock = errors.New(
"is not a name, = and a netblock, such as office=203.0.113.0/24") "is not a name, = and a netblock, such as office=203.0.113.0/24")
errNotAbsolutePath = errors.New( 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") errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe") errNotMode = errors.New("is not enforce or observe")
errNotBytesCount = errors.New("is not response, request or both") errNotBytesCount = errors.New("is not response, request or both")
@@ -401,7 +401,7 @@ var (
"is not the id of a Core Rule Set rule, a whole number such as 942100") "is not the id of a Core Rule Set rule, a whole number such as 942100")
errSetupRuleID = errors.New( errSetupRuleID = errors.New(
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + "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") errNotBoolean = errors.New("is not true or false")
errNotLogRemoteURL = errors.New( errNotLogRemoteURL = errors.New(
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
@@ -413,7 +413,7 @@ var (
errSetTwice = errors.New("set only one of them") errSetTwice = errors.New("set only one of them")
errNotWebhookURL = errors.New( errNotWebhookURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "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( errNotWebhookHeader = errors.New(
"is not a header name followed by : and the header's value, " + "is not a header name followed by : and the header's value, " +
"such as Authorization:Bearer <token>") "such as Authorization:Bearer <token>")
@@ -507,7 +507,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/sww"),
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"),
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
@@ -516,7 +516,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
AdminToken: env.token("SWWAF_ADMIN_TOKEN"), AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"), MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), RulesDir: env.value("SWWAF_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"), WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
@@ -572,26 +572,26 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
return cfg, nil return cfg, nil
} }
// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its // Command reads only SWW_COMMAND, the command the image runs and its
// health check checks: rproxy when it is unset or empty, or servewaf. It cannot // 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. // be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) { func Command(lookupEnv func(string) (string, bool)) (string, error) {
command, _ := lookupEnv("SMALLWEBWAF_COMMAND") command, _ := lookupEnv("SWW_COMMAND")
switch command { switch command {
case "", "rproxy": case "", "rproxy":
return "rproxy", nil return "rproxy", nil
case "servewaf": case "discriminator":
return command, nil return command, nil
default: 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 // DiscriminatorListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in servewaf's container needs // 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. // 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} env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR") listenAddr := env.address("SWWAF_LISTEN_ADDR")
@@ -1395,7 +1395,7 @@ func parseSize(value string) (int64, error) {
} }
// parseWAFBodyLimit reads the most of a request body the Core Rule Set // 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. // gets no more of a body than that.
func parseWAFBodyLimit(value string) (int64, error) { func parseWAFBodyLimit(value string) (int64, error) {
limit, err := parseSize(value) limit, err := parseSize(value)
+18 -17
View File
@@ -254,12 +254,12 @@ func TestDefaults(t *testing.T) {
AttackBanDuration: 7 * 24 * time.Hour, AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000, MaxBans: 5000,
BanScopeV4Prefix: 32, BanScopeV4Prefix: 32,
StateDir: "/var/lib/smallwebwaf", StateDir: "/var/lib/sww",
StateWriteDelay: 10 * time.Second, StateWriteDelay: 10 * time.Second,
StateCounterInterval: 15 * time.Minute, StateCounterInterval: 15 * time.Minute,
MetricsToken: "", MetricsToken: "",
MetricsTopN: 50, MetricsTopN: 50,
RulesDir: "/etc/smallwebwaf/rules.d", RulesDir: "/etc/sww/rules.d",
RulesEnabled: true, RulesEnabled: true,
}) })
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
@@ -588,7 +588,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
const ( const (
notParanoiaLevel = " is not a paranoia level, from 1 to 4" notParanoiaLevel = " is not a paranoia level, from 1 to 4"
setupRule = " is from 900000 to 900999, the ids of the rules that set " + 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 }{ for _, tc := range []struct{ name, value, want string }{
@@ -627,10 +627,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
wafBodyLimit, "128KB", wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"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", 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) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
@@ -856,7 +857,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
const ( const (
webhook = "https://alerts.example:8443/hooks/waf?team=ops" webhook = "https://alerts.example:8443/hooks/waf?team=ops"
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef" slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
ntfy = "https://ntfy.example/smallwebwaf-alerts" ntfy = "https://ntfy.example/sww-alerts"
token = "tk_0123456789abcdefghijklmnopq" token = "tk_0123456789abcdefghijklmnopq"
) )
@@ -904,17 +905,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartStopped(t, []struct{ name, value string }{ wantStartStopped(t, []struct{ name, value string }{
{alertWebhookURL, "alerts.example/smallwebwaf"}, {alertWebhookURL, "alerts.example/sww"},
{alertWebhookURL, "ftp://alerts.example/"}, {alertWebhookURL, "ftp://alerts.example/"},
{alertWebhookURL, "https:///smallwebwaf"}, {alertWebhookURL, "https:///sww"},
{alertWebhookURL, "https://user:password@alerts.example/"}, {alertWebhookURL, "https://user:password@alerts.example/"},
{alertWebhookURL, "https://alerts.example/#top"}, {alertWebhookURL, "https://alerts.example/#top"},
{alertWebhookURL, "https://alerts.example:0/"}, {alertWebhookURL, "https://alerts.example:0/"},
{alertWebhookURL, "https://alerts.example:65536/"}, {alertWebhookURL, "https://alerts.example:65536/"},
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"}, {alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"}, {alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"}, {alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"}, {alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
{alertWebhookHeaders, "Authorization"}, {alertWebhookHeaders, "Authorization"},
{alertWebhookHeaders, "X Team:ops"}, {alertWebhookHeaders, "X Team:ops"},
{alertWebhookHeaders, ":ops"}, {alertWebhookHeaders, ":ops"},
@@ -994,7 +995,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
want := alertWebhookURL + ": is not an http or https URL without a user or " + 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 { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1008,7 +1009,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef", alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", alertNtfyURL: "https://ntfy.example/sww-alerts",
alertNtfyToken: token, alertNtfyToken: token,
}) })
@@ -1027,7 +1028,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) { if strings.Contains(logged, secret) {
t.Errorf("%s in the settings logged: %s", secret, logged) t.Errorf("%s in the settings logged: %s", secret, logged)
} }
@@ -1058,7 +1059,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
const name = "fsn1app1\r" const name = "fsn1app1\r"
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv) }.lookupEnv)
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
@@ -2039,7 +2040,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"}, {stateCounterInterval, off}, {stateCounterInterval, "15"},
}) })
@@ -2310,7 +2311,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
attackBanDuration: "7d", attackBanDuration: "7d",
maxBans: "5000", maxBans: "5000",
banScopeV4Prefix: "32", banScopeV4Prefix: "32",
stateDir: "/var/lib/smallwebwaf", stateDir: "/var/lib/sww",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
adminToken: "", adminToken: "",
@@ -2319,7 +2320,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
instanceName: hostname, instanceName: hostname,
logRequestHeaders: defaultLogRequestHeaders, logRequestHeaders: defaultLogRequestHeaders,
logLevel: "info", logLevel: "info",
rulesDir: "/etc/smallwebwaf/rules.d", rulesDir: "/etc/sww/rules.d",
rulesEnabled: "true", rulesEnabled: "true",
wafMode: config.WAFModeBlock, wafMode: config.WAFModeBlock,
wafParanoiaLevel: "1", wafParanoiaLevel: "1",
+7 -7
View File
@@ -12,7 +12,7 @@ import (
"time" "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. // is off.
type RProxy struct { type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). // 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 // TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES). // (SWRP_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND). // WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
WAFBackend *url.URL 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. // and to a report (SWRP_TIMEOUT_MS). It cannot be off.
Timeout time.Duration 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: // connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION). // allow or deny (SWRP_DEFAULT_DISPOSITION).
DefaultDisposition string DefaultDisposition string
@@ -50,7 +50,7 @@ type RProxy struct {
var ( var (
errNeedsWAFBackend = errors.New( 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") errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New( errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000") "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...) 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 { func (e *environment) wafBackend(name string) *url.URL {
value := e.value(name, "") value := e.value(name, "")
if value == "" { if value == "" {
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
return backend 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: // cannot be connected to, does not answer at all, or does not answer in time:
// allow or deny. // allow or deny.
func (e *environment) disposition(name, defaultValue string) string { func (e *environment) disposition(name, defaultValue string) string {
+3 -3
View File
@@ -24,7 +24,7 @@ const (
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
backendURL = "http://servewaf:8080" backendURL = "http://discriminator:8080"
rproxyDefaultAddr = ":8080" rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081" rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot" notASize = "a lot"
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
name, value string name, value string
}{ }{
{wafBackend, ""}, {wafBackend, ""},
{wafBackend, "servewaf:8080"}, {wafBackend, "discriminator:8080"},
{wafBackend, "http://servewaf:8080/path"}, {wafBackend, "http://discriminator:8080/path"},
{rproxyTimeout, "0"}, {rproxyTimeout, "0"},
{rproxyTimeout, "-1"}, {rproxyTimeout, "-1"},
{rproxyTimeout, off}, {rproxyTimeout, off},
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"bytes" "bytes"
@@ -49,7 +49,7 @@ func (rq *request) answerHealth() {
_, _ = io.WriteString(rq.out, "ok\n") _, _ = 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 <token>: the metrics // needs a token, sent as Authorization: Bearer <token>: the metrics
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is // SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other // refused with 401. An endpoint whose token is unset answers 404, as any other
@@ -250,7 +250,7 @@ type clientAnswer struct {
Bans []state.BanEntry `json:"bans"` Bans []state.BanEntry `json:"bans"`
} }
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client: // showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
// its counters, its history, which holds its country as last looked up and its // its counters, its history, which holds its country as last looked up and its
// offences, and its bans with their notes. // offences, and its bans with their notes.
func (rq *request) showClient() { func (rq *request) showClient() {
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"encoding/json" "encoding/json"
@@ -10,9 +10,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
) )
@@ -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{ for _, e := range []adminEndpoint{
{http.MethodPut, servewaf.BansPath, banOtherClient}, {http.MethodPut, discriminator.BansPath, banOtherClient},
{http.MethodDelete, servewaf.BansPath, ""}, {http.MethodDelete, discriminator.BansPath, ""},
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""}, {http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodPost, servewaf.ClientsPath + otherClient, ""}, {http.MethodPost, discriminator.ClientsPath + otherClient, ""},
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""}, {http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
} { } {
s.admin(e.method, e.path, e.body, http.StatusNotFound) s.admin(e.method, e.path, e.body, http.StatusNotFound)
} }
@@ -118,8 +118,8 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
Reason: "probes for logins", Reason: "probes for logins",
} }
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK), wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
want) http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(expires) { if line.BanExpires != requestlog.FormatTime(expires) {
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused. // Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1 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 // Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted. // kept, marked lifted.
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
lifted := clk.Now() lifted := clk.Now()
want.Lifted = &lifted want.Lifted = &lifted
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
http.StatusOK), want) http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward) 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. // 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) { func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -181,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires 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", "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) { 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) 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"` 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 { if got.Client == nil {
t.Fatalf("%s: no client", addr) 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 // Of an address no request came from and no ban covers, nothing is
// known. // 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" { if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body) 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.MethodGet, discriminator.ClientsPath+"203.0.113", "",
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "", http.StatusBadRequest)
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
http.StatusBadRequest) http.StatusBadRequest)
} }
@@ -342,14 +347,14 @@ type adminEndpoint struct {
// bans on otherClient, and asking about otherClient. // bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint { func adminEndpoints() []adminEndpoint {
return []adminEndpoint{ return []adminEndpoint{
{http.MethodGet, servewaf.BansPath, ""}, {http.MethodGet, discriminator.BansPath, ""},
{http.MethodPost, servewaf.BansPath, banOtherClient}, {http.MethodPost, discriminator.BansPath, banOtherClient},
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""}, {http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodGet, servewaf.ClientsPath + 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 // address, with the admin token, and checks that it is answered with status, its
// log line's action admin. It returns the answer. // log line's action admin. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer { func (s *sender) admin(method, path, body string, status int) answer {
@@ -365,7 +370,7 @@ func (s *sender) adminRequest(
) answer { ) answer {
s.t.Helper() 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 != "" { if authorization != "" {
req.Header.Set("Authorization", authorization) req.Header.Set("Authorization", authorization)
} }
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -10,8 +10,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// never sent them, and returns the queue they wait in as well. // never sent them, and returns the queue they wait in as well.
func startWithAlerts( func startWithAlerts(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env) return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
@@ -210,21 +210,21 @@ func startWithAlerts(
// startAppWithAlerts is startWithAlerts in front of the app handler. // startAppWithAlerts is startWithAlerts in front of the app handler.
func startAppWithAlerts( func startAppWithAlerts(
t *testing.T, handler http.HandlerFunc, env map[string]string, t *testing.T, handler http.HandlerFunc, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
app := startApp(t, handler) app := startApp(t, handler)
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
settings := map[string]string{ settings := map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
alertWebhookURL: "https://alerts.example/smallwebwaf", alertWebhookURL: "https://alerts.example/sww",
instanceName: alertInstance, instanceName: alertInstance,
} }
maps.Copy(settings, env) maps.Copy(settings, env)
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) 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 return s, clk, server, queue
} }
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"net/netip" "net/netip"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -13,9 +13,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "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 // The anomaly thresholds: the prefix of a scope followed by the end of a
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// kept. // kept.
geojsURL, _, release := startHeldGeoJS(t) geojsURL, _, release := startHeldGeoJS(t)
var server atomic.Pointer[servewaf.Server] var server atomic.Pointer[discriminator.Server]
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
release() release()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,16 +15,16 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time // answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// servewaf has to answer. // discriminator has to answer.
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT. // defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
@@ -33,7 +33,7 @@ const (
headerFieldLimit = 100 headerFieldLimit = 100
// cookieHeader is the header the cookie limit counts. // cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie" 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 tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in // silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that unansweredGeoJSURL names too. // 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 // 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 // client sends nothing else but Host, which the limit does not count, once
// User-Agent is set. // User-Agent is set.
func fillHeaders(req *http.Request, total int) { func fillHeaders(req *http.Request, total int) {
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) { func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block}) s := startDiscriminator(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) 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) 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) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
@@ -136,7 +136,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block}) s := startDiscriminator(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
tc.make(req) tc.make(req)
@@ -144,13 +144,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
line := s.out.requestLine(t) line := s.out.requestLine(t)
if tc.refused { if tc.refused {
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
return return
} }
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationWAF == nil { if line.DurationWAF == nil {
@@ -171,11 +171,11 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Run(fmt.Sprint(env), func(t *testing.T) { t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", env) s := startDiscriminator(t, "", env)
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) 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) { func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel() 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 := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) 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) { func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel() 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 := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) 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) 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) line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil { if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
@@ -216,7 +216,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) { func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"}) s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
ask := func() answer { ask := func() answer {
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -224,8 +224,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
return do(t, req) return do(t, req)
} }
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny) wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny) wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited) 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{ s := startDiscriminator(t, "", map[string]string{
trapPaths: trapPathList, rulesDir: writeRules(t, testRules), trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
}) })
req := s.question(siteA, client, tc.path) req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) 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) wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
}) })
} }
@@ -259,13 +259,13 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h", unknownLimitPercent: "0", lookupTimeout: "1h",
}) })
req := s.question(siteA, fromDE, "/") req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) 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) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) 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. // 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) { func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) 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{{ s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512", Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
Answered: s.clock.Now(), Used: s.clock.Now(), Answered: s.clock.Now(), Used: s.clock.Now(),
@@ -291,7 +291,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) 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) wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
} }
@@ -299,14 +299,14 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h", mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
}) })
req := s.question(siteA, fromDE, "/") req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
got := do(t, req) 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") asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != asnDE || country != "DE" { 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 // answers, a setting needs its answer, and everything is at its default but
// for env. No request to it may use the network. // for env. No request to it may use the network.
func newServeWAFInBubble( func newDiscriminatorInBubble(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*servewaf.Server, *output) { ) (*discriminator.Server, *output) {
t.Helper() t.Helper()
settings := map[string]string{deniedCountries: "KP"} settings := map[string]string{deniedCountries: "KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings) server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
return server, out return server, out
} }
@@ -334,13 +334,13 @@ func newServeWAFInBubble(
// askDirectly hands a question about a request from client straight to server, // 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. // as change leaves it, and returns the answer's status and how long it took.
func askDirectly( 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) { ) (int, time.Duration) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234") req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(servewaf.ClientHeader, client) req.Header.Set(discriminator.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
change(req) change(req)
@@ -353,17 +353,17 @@ func askDirectly(
} }
// banClient bans client at now. // 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{}) server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
} }
// countClient counts one request from client at now for the rate limits. // 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) server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
} }
// listClient puts client on the blocklist at dropURL. // 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() t.Helper()
err := server.Lists.Load([]reputation.List{{ 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. // 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() t.Helper()
loadDecisions(t, server, now, clientsDecision) loadDecisions(t, server, now, clientsDecision)
} }
// leaveAsItIs prepares nothing. // leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {} func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Parallel() t.Parallel()
@@ -392,7 +392,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // 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 is the request's Cookie header.
cookie string cookie string
status int status int
@@ -435,7 +435,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all. // says, so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) { 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()) tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) { status, took := askDirectly(t, server, func(req *http.Request) {
@@ -463,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // 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 status int
action string action string
}{ }{
@@ -492,7 +492,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { 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()) tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(*http.Request) {}) 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() t.Parallel()
held, askedGeoJS, _ := startHeldGeoJS(t) held, askedGeoJS, _ := startHeldGeoJS(t)
@@ -521,10 +523,12 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
t.Cleanup(func() { _ = resolver.Close() }) 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 params.AbuseIPDBURL = silentAbuseIPDBURL
return servewaf.New(params) return discriminator.New(params)
}, held, time.Now, map[string]string{ }, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block, deniedCountries: "KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(), dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"net/http" "net/http"
@@ -83,7 +83,7 @@ func (rq *request) countBytes() {
} }
// countRefusal counts the request for the error burst once it has been // 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 // 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 // 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 // break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -15,8 +15,8 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -352,7 +352,7 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) 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. // makes bans.
type clock struct { type clock struct {
mu sync.Mutex mu sync.Mutex
@@ -375,14 +375,14 @@ func (c *clock) advance(d time.Duration) {
c.now = c.now.Add(d) 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 // with the settings in env on top of trusting localhost's
// X-Forwarded-For, clients' AS numbers and countries looked up at // 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 // geojsURL, and a clock set to midnight, the start of a bucket in every
// window. // window.
func startWithClock( func startWithClock(
t *testing.T, geojsURL string, env map[string]string, t *testing.T, geojsURL string, env map[string]string,
) (*sender, *clock, *servewaf.Server) { ) (*sender, *clock, *discriminator.Server) {
t.Helper() t.Helper()
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) 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) 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 // 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, and checks each one's answer and log line. discriminatorAddr is where
// own endpoints are, which adminRequest asks. Every request servewaf gets must // discriminator's own endpoints are, which adminRequest asks. Every request
// come through the sender, since the log lines are matched to them in order. // discriminator gets must come through the sender, since the log lines are
// matched to them in order.
type sender struct { type sender struct {
t *testing.T t *testing.T
addr string addr string
serveWAFAddr string discriminatorAddr string
out *output out *output
sent int sent int
} }
// get sends a GET request for / from the client at from. // get sends a GET request for / from the client at from.
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,9 +15,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The biased thresholds. // The biased thresholds.
@@ -280,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { 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"}) map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client // 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 // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { 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}) map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
began := time.Now() began := time.Now()
@@ -419,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// clock. // clock.
func startWithLookups( func startWithLookups(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *servewaf.Server, *alerts.Queue) { ) (*sender, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
s, _, server, queue := startWithLookupsAndClock(t, env) s, _, server, queue := startWithLookupsAndClock(t, env)
@@ -434,7 +434,7 @@ func startWithLookups(
// country, and no other address. // country, and no other address.
func startWithLookupsAndClock( func startWithLookupsAndClock(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") 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 // 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 // straight to server's handler, without the network, and returns once it is
// answered. // 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() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, "/", body) 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") req.Header.Set(requestIDHeader, "the-id")
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -92,7 +92,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
s.get(client, http.StatusForbidden, requestlog.ActionBanned) 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) `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
}) })
} }
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"crypto/rand" "crypto/rand"
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"time" "time"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -367,10 +367,10 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, 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, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3) `sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
`instance="app",status_class="4xx"}`, 3) `instance="app",status_class="4xx"}`, 3)
} }
@@ -394,9 +394,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, 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, wantNoSeries(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`) `sww_offences_total{instance="app",kind="waf_blocked"}`)
} }
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"slices" "slices"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"encoding/json" "encoding/json"
@@ -350,7 +350,7 @@ const (
) )
// geojsAnswer is an answer of GeoJS about one address, with the fields // geojsAnswer is an answer of GeoJS about one address, with the fields
// smallwebwaf reads. // sww reads.
// //
//nolint:tagliatelle // GeoJS's own names //nolint:tagliatelle // GeoJS's own names
type geojsAnswer struct { type geojsAnswer struct {
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -9,9 +9,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The CrowdSec settings, and the tests' engine, which is never asked: each // The CrowdSec settings, and the tests' engine, which is never asked: each
@@ -87,11 +87,11 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
metrics := s.scrape() metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}` 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) `instance="`+alertInstance+`"}`, 1)
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
float64(fetched.Unix())) float64(fetched.Unix()))
} }
@@ -168,7 +168,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
// decisionsURL, answer, the engine's answer, fetched at fetched, as // decisionsURL, answer, the engine's answer, fetched at fetched, as
// reputation.json would at start. // reputation.json would at start.
func loadDecisions( 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() t.Helper()
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"bytes" "bytes"
@@ -17,18 +17,18 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The headers of rproxy's question and of servewaf's answer, as // The headers of rproxy's question and of discriminator's answer, as
// "servewaf" in SPEC.md gives them. // "discriminator" in SPEC.md gives them.
const ( const (
// ClientHeader marks a request as rproxy's question, and gives the // ClientHeader marks a request as rproxy's question, and gives the
// visitor's address. // visitor's address.
ClientHeader = "X-Smallwebwaf-Client" ClientHeader = "X-Sww-Client"
// PeerHeader gives the address of rproxy's own TCP peer. // 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 gives the body length the visitor announced.
ContentLengthHeader = "X-Smallwebwaf-Content-Length" ContentLengthHeader = "X-Sww-Content-Length"
// DispositionHeader gives servewaf's answer. // DispositionHeader gives discriminator's answer.
DispositionHeader = "X-Smallwebwaf-Disposition" DispositionHeader = "X-Sww-Disposition"
) )
// The values of DispositionHeader. // The values of DispositionHeader.
@@ -40,14 +40,14 @@ const (
DispositionDeny = "deny" DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection // DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close. // without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
// servewaf closing the question's connection instead would read as // discriminator closing the question's connection instead would read as
// servewaf being down, and rproxy would then let the request through. // discriminator being down, and rproxy would then let the request through.
DispositionClose = "close" 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. // answered.
const ReportPath = "/_smallwebwaf/report" const ReportPath = "/_sww/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
@@ -76,11 +76,11 @@ const (
// maxReportedMilliseconds is the longest duration a report may give, // maxReportedMilliseconds is the longest duration a report may give,
// about 31 years, so that it fits a time.Duration. // about 31 years, so that it fits a time.Duration.
maxReportedMilliseconds = 1e12 maxReportedMilliseconds = 1e12
// serveWAFTimeout bounds how long a request to servewaf takes to arrive, // discriminatorTimeout bounds how long a request to discriminator takes to
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open // arrive, and its answer to leave, and discriminatorIdleTimeout how long a
// connection waits for its next request. // kept-open connection waits for its next request.
serveWAFTimeout = 60 * time.Second discriminatorTimeout = 60 * time.Second
serveWAFIdleTimeout = 120 * time.Second discriminatorIdleTimeout = 120 * time.Second
) )
var ( var (
@@ -103,7 +103,7 @@ type Report struct {
DurationUpstreamTotal float64 `json:"duration_upstream_total"` 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 // 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 // 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 // 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, Addr: params.Config.ListenAddr,
Handler: h, Handler: h,
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn), ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
ReadTimeout: serveWAFTimeout, ReadTimeout: discriminatorTimeout,
WriteTimeout: serveWAFTimeout, WriteTimeout: discriminatorTimeout,
IdleTimeout: serveWAFIdleTimeout, IdleTimeout: discriminatorIdleTimeout,
}, },
Ledger: h.ledger, Ledger: h.ledger,
Limiter: h.limiter, Limiter: h.limiter,
@@ -133,8 +133,9 @@ func New(params Params) *Server {
} }
} }
// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is // ServeHTTP handles one request to discriminator: a request carrying
// rproxy's question, and any other is for servewaf itself. // ClientHeader is rproxy's question, and any other is for discriminator
// itself.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 { if len(r.Header.Values(ClientHeader)) > 0 {
h.answerQuestion(w, r) h.answerQuestion(w, r)
@@ -150,7 +151,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// its client: it is over the header limits, a blocklist lists its client while // 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 // SWWAF_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. // 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 { func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe { if rq.h.config.Observe {
return false return false
@@ -165,7 +166,7 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)) 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 // 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 // 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 // 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() defer rq.finish()
rq.site = r.Host rq.site = r.Host
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
@@ -238,7 +239,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return 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. // own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionAdmin
rq.out.Header().Set(DispositionHeader, DispositionDeny) 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, // 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 // 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 // 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 // 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) held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
if held { 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 return netip.Addr{}, nil, false
} }
@@ -297,14 +298,15 @@ func (h *handler) readQuestion(
return client, body, true 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) { func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
h.processLog.Warn("a question is refused", "reason", reason) h.processLog.Warn("a question is refused", "reason", reason)
http.Error(w, reason, http.StatusBadRequest) http.Error(w, reason, http.StatusBadRequest)
} }
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the // 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, // 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 // as -1 when it announced none. The question's own length covers only the part
// of the body it carries. // of the body it carries.
@@ -372,7 +374,7 @@ func (rq *request) deny(r refusal) {
http.Error(rq.out, http.StatusText(r.status), r.status) 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 // runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors. // rproxy and the admin's tools, not visitors.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) { 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, requestBytes: report.RequestBytes,
} }
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
rq.line.Status = report.Status rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes rq.line.ResponseBytes = report.ResponseBytes
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -10,14 +10,14 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWWAF_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. // file match.
type refused int type refused int
@@ -51,7 +51,7 @@ func (r refused) send(s *sender, from string) logLine {
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. // SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst( func startForErrorBurst(
t *testing.T, threshold string, env map[string]string, t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *servewaf.Server) { ) (*sender, *clock, *discriminator.Server) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -156,12 +156,12 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
} }
metrics := s.scrape() 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) `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, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3) `sww_offences_total{instance="app",kind="rule_blocked"}`, 3)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
} }
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) { func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
@@ -1,11 +1,11 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
) )
for range healthChecks { 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 { if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -8,9 +8,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
Requests: 4, Requests: 4,
Forwarded: 2, Forwarded: 2,
Refused: 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")), ResponseBytes: 2 * int64(len("Forbidden\n")),
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2}, Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
Offences: ratelimit.Offences{Limit: 1}, Offences: ratelimit.Offences{Limit: 1},
@@ -100,8 +100,9 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK) s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized) s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "",
http.StatusUnauthorized)
s.scrape() s.scrape()
if clients := server.Limiter.Snapshot(); len(clients) != 0 { 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) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil) 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) out.endedLine(t)
history := historyOf(t, server, localhost) history := historyOf(t, server, localhost)
@@ -126,7 +127,9 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
} }
// historyOf returns the history of the client at addr. // 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() t.Helper()
client := netip.MustParsePrefix(addr + "/32") client := netip.MustParsePrefix(addr + "/32")
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now, addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
alertWebhookURL: "https://alerts.example/smallwebwaf", alertWebhookURL: "https://alerts.example/sww",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
s := &sender{t: t, addr: addr, out: out} 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, // 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 // 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 // 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) { synctest.Test(t, func(t *testing.T) {
// Not the default. The exclusive list needs the answer, and the app // Not the default. The exclusive list needs the answer, and the app
// is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), lookupTimeout: timeout.String(),
allowedCountries: "DE", allowedCountries: "DE",
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -11,9 +11,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "", s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
http.StatusNotFound) http.StatusNotFound)
} }
} }
@@ -46,18 +46,19 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
method, path, authorization string method, path, authorization string
status int 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.StatusUnauthorized,
}, },
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized}, {http.MethodGet, discriminator.MetricsPath, "Basic " + token,
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK}, http.StatusUnauthorized},
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK}, {http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound}, {http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
{http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound}, {http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound}, {http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
{http.MethodPost, servewaf.HealthPath, "", 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) 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 && 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) 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"))) got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
wantStatus(t, got, http.StatusOK) 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) out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}` 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. // The request for the metrics is itself under way.
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1) wantMetric(t, metrics, "sww_requests_total"+forward, 1)
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1) wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3) wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5) wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound, wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
float64(len("Not Found\n"))) float64(len("Not Found\n")))
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2) `sww_request_duration_seconds_count{instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1) `sww_upstream_duration_seconds_count{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1) wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
metric(t, metrics, `go_goroutines{instance="app"}`) metric(t, metrics, `go_goroutines{instance="app"}`)
metric(t, metrics, `process_start_time_seconds{instance="app"}`) metric(t, metrics, `process_start_time_seconds{instance="app"}`)
} }
@@ -132,16 +133,16 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1) `sww_requests_total{action="denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 1) `kind="requests",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_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
clk.advance(time.Hour) 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 // A limit broken again right after would ban for three hours, longer
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent. // than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
@@ -149,14 +150,14 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionRateLimited)
metrics = s.scrape() 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) `kind="requests",window="minute"}`, 2)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
// denied and client. // 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) { func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
@@ -167,7 +168,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
adminToken: adminSecret, 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) wantMetric(t, s.scrape(), admins, 0)
@@ -180,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
wantMetric(t, s.scrape(), admins, 1) wantMetric(t, s.scrape(), admins, 1)
// And a ban made through the endpoint. // 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) wantMetric(t, s.scrape(), admins, 2)
} }
@@ -233,25 +234,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
lines++ lines++
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) `sww_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2) `sww_country_requests_total{country="DE",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1) `sww_country_requests_total{country="other",instance="app"}`, 1)
wantMetric(t, metrics, 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, 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, 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, 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"))) float64(3*len("Forbidden\n")))
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`, `sww_country_response_bytes_total{country="other",instance="app"}`,
float64(len("hello"))) float64(len("hello")))
wantNoSeries(t, metrics, 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 // Once FR is busier than DE, it takes DE's place: its series counts
// from then on, and DE's is gone. // from then on, and DE's is gone.
@@ -259,15 +260,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
metrics = scrape(t, server.Addr) metrics = scrape(t, server.Addr)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) `sww_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2) `sww_country_requests_total{country="FR",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2) `sww_country_requests_total{country="other",instance="app"}`, 2)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_country_requests_total{country="DE",instance="app"}`) `sww_country_requests_total{country="DE",instance="app"}`)
wantNoSeries(t, metrics, 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) { func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
@@ -298,15 +299,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2) `sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1) `sww_asn_requests_total{asn="other",instance="app"}`, 1)
wantMetric(t, metrics, 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, 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, wantNoSeries(t, metrics,
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`) `sww_asn_requests_total{asn="AS64502",instance="app"}`)
} }
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
@@ -337,16 +338,16 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr) 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.Now().Before(deadline) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
metrics = scrape(t, server.Addr) metrics = scrape(t, server.Addr)
} }
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_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 // 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. // them.
func scrape(t *testing.T, addr string) string { func scrape(t *testing.T, addr string) string {
t.Helper() 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) req.Header.Set("Authorization", bearer)
got := do(t, req) got := do(t, req)
@@ -380,14 +381,14 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string { func (s *sender) scrape() string {
s.t.Helper() s.t.Helper()
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "", return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "",
http.StatusOK).body) http.StatusOK).body)
} }
// metric returns the value of series in metrics, which are in the // 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 // Prometheus text format. series is a name and its labels in the order of
// their names, such as // 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. // test if there is no such series.
func metric(t *testing.T, metrics, series string) float64 { func metric(t *testing.T, metrics, series string) float64 {
t.Helper() t.Helper()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -1,7 +1,7 @@
// Package servewaf is the server the servewaf command runs. It answers rproxy's // Package discriminator is the server the discriminator command runs. It answers
// questions, takes rproxy's reports and answers its own endpoints, and writes a // rproxy's questions, takes rproxy's reports and answers its own endpoints, and
// log line for each. // writes a log line for each.
package servewaf package discriminator
import ( import (
"io" "io"
@@ -23,31 +23,32 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "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. // 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. // container's health check asks.
const HealthPath = "/_smallwebwaf/healthz" const HealthPath = "/_sww/healthz"
// MetricsPath is where the metrics are, for a request that carries // MetricsPath is where the metrics are, for a request that carries
// SWWAF_METRICS_TOKEN. // SWWAF_METRICS_TOKEN.
const MetricsPath = "/_smallwebwaf/metrics" const MetricsPath = "/_sww/metrics"
// BansPath is where an admin lists and adds bans, and, followed by / and // BansPath is where an admin lists and adds bans, and, followed by / and
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN. // a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
const BansPath = "/_smallwebwaf/bans" const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what servewaf knows of a client, by the // ClientsPath is where an admin asks what discriminator knows of a client, by
// client's address after it, with SWWAF_ADMIN_TOKEN. // the client's address after it, with SWWAF_ADMIN_TOKEN.
const ClientsPath = "/_smallwebwaf/clients/" const ClientsPath = "/_sww/clients/"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
// RequestLog receives two JSON lines per question, one at its decision and // 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 RequestLog io.Writer
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
@@ -67,7 +68,7 @@ type Params struct {
// Rules are the rule files' rules, which each request is checked // Rules are the rule files' rules, which each request is checked
// against. // against.
Rules *rules.Files 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 // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // 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 // AbuseIPDB lists, for each request the Core Rule Set scores at or over
@@ -77,7 +78,7 @@ type Params struct {
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server servewaf runs, with the parts whose state the state // Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the // files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. // AbuseIPDB's scores and checks spent, and the metrics.
@@ -227,7 +228,7 @@ func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
return coreRuleSet return coreRuleSet
} }
// 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. // belongs to one request is in a request.
type handler struct { type handler struct {
config *config.Config config *config.Config
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -17,11 +17,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -90,13 +90,13 @@ const off = "off"
// on is the value that switches a setting that takes true or false on. // on is the value that switches a setting that takes true or false on.
const on = "true" const on = "true"
// output collects what smallwebwaf writes on stdout. // output collects what sww writes on stdout.
type output struct { type output struct {
mu sync.Mutex mu sync.Mutex
buf bytes.Buffer buf bytes.Buffer
} }
// Write adds lines smallwebwaf writes. // Write adds lines sww writes.
func (o *output) Write(p []byte) (int, error) { func (o *output) Write(p []byte) (int, error) {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
@@ -175,9 +175,9 @@ func (o *output) requestLine(t *testing.T) logLine {
return o.requestLines(t, 1)[0] 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 // 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 { func (o *output) endedLines(t *testing.T, count int) []logLine {
t.Helper() t.Helper()
@@ -187,7 +187,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
ended := len(found) >= count ended := len(found) >= count
for _, line := range found[:min(count, len(found))] { for _, line := range found[:min(count, len(found))] {
if line.ServeWAFLine == requestlog.ServeWAFLineDecision { if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
ended = false 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, // 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 { func (o *output) lastLines(t *testing.T) []logLine {
t.Helper() t.Helper()
@@ -271,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
return 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 // 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) { func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
t.Helper() t.Helper()
@@ -293,14 +293,14 @@ func startProxyWithGeoJS(
} }
// startProxyWithClock is startProxyWithGeoJS with requests counted and // startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns servewaf's server as well, whose // bans made by the time now tells, and returns discriminator's server as well, whose
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty // Addr is where discriminator 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, // directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
// the label instance of every metric. // the label instance of every metric.
func startProxyWithClock( func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
) (string, *output, *servewaf.Server) { ) (string, *output, *discriminator.Server) {
t.Helper() t.Helper()
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env) addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
@@ -309,14 +309,14 @@ func startProxyWithClock(
} }
// startProxyWithAlerts is startProxyWithClock, and returns the queue of // 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( func startProxyWithAlerts(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
) (string, *output, *servewaf.Server, *alerts.Queue) { ) (string, *output, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
server, out, alertQueue := newServeWAF(t, geojsURL, now, env) server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
serve(t, server) serve(t, server)
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue 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 // serve serves server on a port of its own until the test ends, and sets its
// Addr to where it listens. // Addr to where it listens.
func serve(t *testing.T, server *servewaf.Server) { func serve(t *testing.T, server *discriminator.Server) {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") 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 // startRProxy starts rproxy in front of the app at appURL, asking the discriminator
// at serveWAFAddr, with the settings in env on top of the defaults, and returns // 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 // 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. // env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
func startRProxy( func startRProxy(
t *testing.T, serveWAFAddr, appURL string, env map[string]string, t *testing.T, discriminatorAddr, appURL string, env map[string]string,
) string { ) string {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr, "SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
"SWRP_UPSTREAM_URL": appURL, "SWRP_UPSTREAM_URL": appURL,
"SWRP_TIMEOUT_MS": "60000", "SWRP_TIMEOUT_MS": "60000",
} }
@@ -390,7 +390,8 @@ func startRProxy(
return listener.Addr().String() 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 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. // 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 // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
@@ -399,19 +400,19 @@ func startRProxy(
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless // at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
// env sets it, so that only the tests of the Core Rule Set have their requests // env sets it, so that only the tests of the Core Rule Set have their requests
// inspected by it. // inspected by it.
func newServeWAF( func newDiscriminator(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string, 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() 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. // newDiscriminatorWith is newDiscriminator for the server build makes.
func newServeWAFWith( func newDiscriminatorWith(
t *testing.T, build func(servewaf.Params) *servewaf.Server, t *testing.T, build func(discriminator.Params) *discriminator.Server,
geojsURL string, now func() time.Time, env map[string]string, geojsURL string, now func() time.Time, env map[string]string,
) (*servewaf.Server, *output, *alerts.Queue) { ) (*discriminator.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -463,7 +464,7 @@ func newServeWAFWith(
} }
} }
server := build(servewaf.Params{ server := build(discriminator.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
ProcessLog: processLog, ProcessLog: processLog,
@@ -507,7 +508,7 @@ func readAnswer(res *http.Response) answer {
return answer{status: res.StatusCode, header: res.Header, body: body, err: err} 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 { func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
t.Helper() t.Helper()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,10 +15,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The reputation settings. // The reputation settings.
@@ -299,13 +299,13 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}` labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
if want.hits == 0 { if want.hits == 0 {
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels) wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels)
} else { } 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, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
want.fetched) want.fetched)
} }
} }
@@ -537,9 +537,9 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
for _, zone := range []string{dnsblZone, otherZone} { for _, zone := range []string{dnsblZone, otherZone} {
labels := `{instance="` + alertInstance + `",source="` + zone + `"}` labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
} }
} }
@@ -567,7 +567,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+ wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+
alertInstance+`",source="`+masked+`"}`, 1) alertInstance+`",source="`+masked+`"}`, 1)
for name, shown := range map[string]string{ for name, shown := range map[string]string{
@@ -617,7 +617,7 @@ const (
// AbuseIPDB. // AbuseIPDB.
const abuseipdb = reputation.AbuseIPDBSource 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, // abuseIPDBStandIn, which TestMain registers with Go's default transport,
// through which AbuseIPDB is asked. // through which AbuseIPDB is asked.
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check" const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
@@ -863,10 +863,10 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
metrics := s.scrape() metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}` labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900) wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
} }
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) { func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
@@ -887,7 +887,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0) 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+`"}`) `instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
} }
@@ -975,7 +975,7 @@ func verdictsFetched() time.Time {
// verdictsFetched, as reputation.json would at start: one that lists the // 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 // client from each zone listedBy gives for it, and one that does not from
// each other zone. // 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())) verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
for client, zones := range listedBy { 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 // loadScores puts into server's AbuseIPDB the score scores gives each
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json // client, an IPv4 address, fetched at verdictsFetched, as reputation.json
// would at start. // 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)) kept := make([]reputation.Score, 0, len(scores))
for client, score := range scores { for client, score := range scores {
kept = append(kept, reputation.Score{ 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 // wantAbuseIPDBChecks checks how many clients server has checked with
// AbuseIPDB. // AbuseIPDB.
func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) { func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
t.Helper() t.Helper()
if got := server.AbuseIPDB.Checked(); got != want { 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 // loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as reputation.json would at start. // 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() t.Helper()
lists := make([]reputation.List, 0, len(copies)) lists := make([]reputation.List, 0, len(copies))
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"context" "context"
@@ -17,7 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "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. // to close its connection without an answer, and the action the log line names.
type refusal struct { type refusal struct {
status int status int
@@ -25,7 +25,7 @@ type refusal struct {
} }
// decision is what the checks note of a request: its log line, and what // 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 // 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. // never does, and neither the request's objects nor their bodies.
type decision struct { 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 is the path with the query, as a ban's notes keep it.
requestURI string requestURI string
// site is, for a question, the host the visitor asked for, which the // 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 site string
// allowed is true for a question answered with DispositionAllow. // allowed is true for a question answered with DispositionAllow.
allowed bool allowed bool
@@ -63,7 +63,7 @@ type decision struct {
reputation []bans.ReputationHit 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. // read to its log line.
type request struct { type request struct {
decision decision
@@ -425,7 +425,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client)) 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. // size for the log line, and the first error writing the answer.
type responseWriter struct { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -10,9 +10,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -104,7 +104,7 @@ func wantFullLine(t *testing.T, line logLine) {
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"} headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Time: line.Time, Instance: instance, Time: line.Time, Instance: instance,
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure, ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
Host: appHost, Path: "/full", Protocol: protocol, Host: appHost, Path: "/full", Protocol: protocol,
@@ -130,7 +130,7 @@ func wantBareLine(t *testing.T, line logLine) {
t.Helper() t.Helper()
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Time: line.Time, Instance: instance, Time: line.Time, Instance: instance,
ClientIP: localhost, Method: http.MethodPost, Scheme: plain, ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
Host: appHost, Path: "/bare", Protocol: protocol, Host: appHost, Path: "/bare", Protocol: protocol,
@@ -220,7 +220,7 @@ func TestTimingsAreInOrder(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
refused := out.endedLines(t, 2)[1] 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] health := out.endedLines(t, 3)[2]
// A request passed to the app has every timing; one refused, none of // A request passed to the app has every timing; one refused, none of
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -196,18 +196,18 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, 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, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2) wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
`instance="app",status_class="4xx"}`, 1) `instance="app",status_class="4xx"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1) `sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
} }
// writeRules writes content as a rule file into a new directory, and // writeRules writes content as a rule file into a new directory, and
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"slices" "slices"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -157,9 +157,9 @@ type sentRequest struct {
action string 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 // 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) { func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
t.Helper() t.Helper()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
+1 -1
View File
@@ -51,7 +51,7 @@ type File struct {
} }
// record is what the lookup database holds about a network, of the fields // record is what the lookup database holds about a network, of the fields
// smallwebwaf reads. // sww reads.
type record struct { type record struct {
ASN string `maxminddb:"asn"` ASN string `maxminddb:"asn"`
ASName string `maxminddb:"as_name"` ASName string `maxminddb:"as_name"`
+1 -1
View File
@@ -17,7 +17,7 @@ import (
const fileMode = 0o600 const fileMode = 0o600
// Network is what a lookup database holds about a netblock, of the fields // 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. // its country, such as DE.
type Network struct { type Network struct {
ASN string ASN string
+4 -4
View File
@@ -41,7 +41,7 @@ type busiest struct {
// for the topN busiest countries. // for the topN busiest countries.
func newCountries(topN int) *busiest { func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country") 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.", "Requests the country lists refused, by the client's country.",
[]string{"country"}) []string{"country"})
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
return &busiest{ return &busiest{
topN: topN, topN: topN,
requests: counterVec("smallwebwaf_"+label+"_requests_total", requests: counterVec("sww_"+label+"_requests_total",
"Requests, by "+description+".", by), "Requests, by "+description+".", by),
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total", requestBytes: counterVec("sww_"+label+"_request_bytes_total",
"Request body bytes, by "+description+".", by), "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), "Response body bytes, by "+description+".", by),
seen: map[string]int64{}, seen: map[string]int64{},
top: map[string]bool{}, top: map[string]bool{},
+41 -41
View File
@@ -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 // docs/metrics.md lists them, and serves them in the Prometheus text
// format, as the "Metrics endpoint" section of SPEC.md requires. No // format, as the "Metrics endpoint" section of SPEC.md requires. No
// metric carries a client's address. // metric carries a client's address.
@@ -22,7 +22,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "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 { type Metrics struct {
// registry gives every metric registered with it the label instance. // registry gives every metric registered with it the label instance.
registry prometheus.Registerer registry prometheus.Registerer
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
prometheus.Labels{"instance": instanceName}, registry), prometheus.Labels{"instance": instanceName}, registry),
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{ inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "smallwebwaf_requests_in_flight", Name: "sww_requests_in_flight",
Help: "Requests under way.", 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), "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.", "Request body bytes, by the class of the status and the action.",
byStatus), 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.", "Response body bytes, by the class of the status and the action.",
byStatus), byStatus),
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ 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.", Help: "How long requests took, from their arrival to their end.",
}), }),
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ 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.", 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 "+ "Requests that broke a rate limit, a byte limit or the error burst, by "+
"its window and its kind, requests, bytes or refusals.", "its window and its kind, requests, bytes or refusals.",
[]string{"window", "kind"}), []string{"window", "kind"}),
offences: counterVec("smallwebwaf_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
wafMatches: counterVec("smallwebwaf_waf_matches_total", wafMatches: counterVec("sww_waf_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+ "Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}), "and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN), countries: newCountries(topN),
asns: newASNs(topN), asns: newASNs(topN),
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "smallwebwaf_geojs_requests_total", Name: "sww_geojs_requests_total",
Help: "Requests to GeoJS.", Help: "Requests to GeoJS.",
}), }),
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "smallwebwaf_geojs_failures_total", Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.", Help: "Requests to GeoJS that failed.",
}), }),
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{ 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 " + Help: "Requests that needed their client's answer from GeoJS and " +
"went on without it, because GeoJS had not given it in time.", "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), "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), "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), "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), "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), "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 <name>.bad because they did not parse.", "Edits of each state file renamed to <name>.bad because they did not parse.",
byFile), byFile),
} }
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec, bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
} { } {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_bans_made_total", Name: "sww_bans_made_total",
Help: "Bans made, by cause.", Help: "Bans made, by cause.",
ConstLabels: prometheus.Labels{"cause": cause}, ConstLabels: prometheus.Labels{"cause": cause},
}, func() float64 { }, func() float64 {
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_active_bans", Name: "sww_active_bans",
Help: "Bans active now, the permanent ones included.", Help: "Bans active now, the permanent ones included.",
}, func() float64 { }, func() float64 {
active, _ := ledger.Count(now()) active, _ := ledger.Count(now())
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
return float64(active) return float64(active)
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_permanent_bans", Name: "sww_permanent_bans",
Help: "Permanent bans not lifted.", Help: "Permanent bans not lifted.",
}, func() float64 { }, func() float64 {
_, permanent := ledger.Count(now()) _, permanent := ledger.Count(now())
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
return float64(permanent) return float64(permanent)
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_tracked_clients", Name: "sww_tracked_clients",
Help: "Clients in the table of clients.", Help: "Clients in the table of clients.",
}, func() float64 { }, func() float64 {
return float64(limiter.Len()) 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 // ruleFiles, read as the metrics are asked for. It is called once, before
// RuleMatched. // RuleMatched.
func (m *Metrics) AddRules(ruleFiles *rules.Files) { 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.", "Requests that matched a rule of the rule files, by its id and action.",
[]string{"rule_id", "action"}) []string{"rule_id", "action"})
m.registry.MustRegister(m.ruleMatches, m.registry.MustRegister(m.ruleMatches,
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_rules_loaded", Name: "sww_rules_loaded",
Help: "Rules loaded from the rule files.", Help: "Rules loaded from the rule files.",
}, func() float64 { }, func() float64 {
return float64(ruleFiles.Len()) return float64(ruleFiles.Len())
@@ -219,20 +219,20 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_remote_log_lines_sent_total", Name: "sww_remote_log_lines_sent_total",
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.", Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
}, func() float64 { }, func() float64 {
return float64(remote.Sent()) return float64(remote.Sent())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ 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 " + Help: "Log lines dropped: the oldest in a full buffer, and those " +
"whose sending failed.", "whose sending failed.",
}, func() float64 { }, func() float64 {
return float64(remote.Dropped()) return float64(remote.Dropped())
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 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.", Help: "Log lines in the buffer, waiting to be sent.",
}, func() float64 { }, func() float64 {
return float64(remote.Depth()) 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) { func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 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.", Help: "When the lookup database in use was read, in seconds since 1970.",
}, func() float64 { }, func() float64 {
return float64(lastRead().Unix()) return float64(lastRead().Unix())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ 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.", Help: "Replacements of the lookup database that could not be read.",
}, func() float64 { }, func() float64 {
return float64(readFailures()) 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 // copy in use was fetched, and for a zone, the queries made and those that
// failed. It is called once, before ReputationHit. // failed. It is called once, before ReputationHit.
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) { 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 "+ "Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.", "zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
[]string{sourceLabel}) []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.addReputationFailures(source, func() int { return lists.Failures(listURL) })
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 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 " + Help: "When the copy of the list in use was fetched, in seconds since " +
"1970, or 0 while there is none.", "1970, or 0 while there is none.",
ConstLabels: source, ConstLabels: source,
@@ -321,7 +321,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
m.addReputationFailures(source, abuseIPDB.Failures) m.addReputationFailures(source, abuseIPDB.Failures)
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_reputation_daily_budget_remaining", Name: "sww_reputation_daily_budget_remaining",
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.", Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
@@ -348,21 +348,21 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_sent_total", Name: "sww_alerts_sent_total",
Help: "Alerts the destination took.", Help: "Alerts the destination took.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).Sent) return float64(queue.Counts(name).Sent)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_failed_total", Name: "sww_alerts_failed_total",
Help: "Requests to the destination that failed.", Help: "Requests to the destination that failed.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).Failed) return float64(queue.Counts(name).Failed)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_suppressed_total", Name: "sww_alerts_suppressed_total",
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " + Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.", "alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
ConstLabels: destination, ConstLabels: destination,
@@ -370,7 +370,7 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
return float64(queue.Suppressed()) return float64(queue.Suppressed())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ 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 " + Help: "Alerts dropped, the oldest first, from a full queue, and " +
"alerts given up as the destination refused them.", "alerts given up as the destination refused them.",
ConstLabels: destination, ConstLabels: destination,
@@ -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 // 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) { func (m *Metrics) StateFileEditTakenIn(name string) {
m.stateFileEditsTakenIn.WithLabelValues(name).Inc() 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. // zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) { func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ 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.", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
// that failed, which count tells. // that failed, which count tells.
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) { func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ 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 " + Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
"AbuseIPDB, that failed.", "AbuseIPDB, that failed.",
ConstLabels: source, ConstLabels: source,
+1 -1
View File
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
{Forwarded: true, Status: 502, ResponseBytes: 12}, {Forwarded: true, Status: 502, ResponseBytes: 12},
// Closed without an answer: refused, and no response. // Closed without an answer: refused, and no response.
{Refused: true, Status: 0}, {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. // refused.
{Status: 404}, {Status: 404},
} { } {
+6 -6
View File
@@ -25,7 +25,7 @@ const (
KindRequests = "requests" KindRequests = "requests"
// KindBytes is a byte limit, on a client's bytes. // KindBytes is a byte limit, on a client's bytes.
KindBytes = "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. // refused after a rule file match or for a missing or wrong token.
KindRefusals = "refusals" KindRefusals = "refusals"
) )
@@ -92,9 +92,9 @@ type History struct {
ASName string `json:"as_name,omitempty"` ASName string `json:"as_name,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
LookedUp time.Time `json:"looked_up,omitzero"` 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 // Refused those it refused, and neither those for a path under
// /_smallwebwaf/. // /_sww/.
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Forwarded int64 `json:"forwarded"` Forwarded int64 `json:"forwarded"`
Refused int64 `json:"refused"` Refused int64 `json:"refused"`
@@ -135,8 +135,8 @@ type Offences struct {
// Request is what a client's history keeps of one of its requests. // Request is what a client's history keeps of one of its requests.
type Request struct { type Request struct {
// Forwarded is true for a request servewaf allowed, and Refused for one it // Forwarded is true for a request discriminator allowed, and Refused for one it
// refused. Both are false for a request for a path under /_smallwebwaf/. // refused. Both are false for a request for a path under /_sww/.
Forwarded bool Forwarded bool
Refused bool Refused bool
// Status is what the client was sent, 0 if nothing was. // Status is what the client was sent, 0 if nothing was.
@@ -258,7 +258,7 @@ func (l *Limiter) CountBytes(
return l.count(client, now, 0, bytes, percent) 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 // 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 // wrong token, and reports whether the client's refusals in the minute
// that ends at now, this one included, are more than threshold, which // that ends at now, this one included, are more than threshold, which
+1 -1
View File
@@ -1,4 +1,4 @@
// Package remotelog sends the lines smallwebwaf writes on stdout to the // Package remotelog sends the lines sww writes on stdout to the
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section // remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
// of SPEC.md describes: each line as the message of an RFC 5424 syslog // 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 // record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
+1 -1
View File
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
template := &x509.Certificate{ template := &x509.Certificate{
SerialNumber: big.NewInt(1), 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), NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
IsCA: true, IsCA: true,
+2 -2
View File
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
"failures_total": "1", "failures_total": "1",
"daily_budget_remaining": "3", "daily_budget_remaining": "3",
} { } {
line := "\nsmallwebwaf_reputation_" + series + line := "\nsww_reputation_" + series +
`{instance="app",source="abuseipdb"} ` + want + "\n" `{instance="app",source="abuseipdb"} ` + want + "\n"
if !strings.Contains(scraped, line) { if !strings.Contains(scraped, line) {
t.Errorf("metrics\n%s\nwant%s", 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. // 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, // 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( func hitFrom(
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool, t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
) (int64, bool) { ) (int64, bool) {
+1 -1
View File
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1", "queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
"failures_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) { if !strings.Contains(scraped.Body.String(), line) {
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line) t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
} }
+1 -1
View File
@@ -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 // 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, // 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 // 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. // so that a restart waits for it: the server may have had its request.
func (l *Lists) fetch(ctx context.Context, listURL string) { func (l *Lists) fetch(ctx context.Context, listURL string) {
lines, err := l.get(ctx, listURL) lines, err := l.get(ctx, listURL)
+23 -23
View File
@@ -1,6 +1,6 @@
// Package requestlog writes the lines smallwebwaf prints on stdout: one // Package requestlog writes the lines sww prints on stdout: one
// JSON object per request, two from servewaf and a short one from rproxy, marked // JSON object per request, two from discriminator and a short one from rproxy,
// "type":"request", and the process's own messages as JSON lines marked // marked "type":"request", and the process's own messages as JSON lines marked
// "type":"process". // "type":"process".
package requestlog package requestlog
@@ -14,10 +14,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "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 ( const (
// ActionForward is a request servewaf allowed, which rproxy passes to the // ActionForward is a request discriminator allowed, which rproxy passes to
// app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request over the header limits. // ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
@@ -42,8 +42,8 @@ const (
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionAdmin is a request servewaf answered at one of its own endpoints, // ActionAdmin is a request discriminator answered at one of its own
// or a question about a path under /_smallwebwaf/. // endpoints, or a question about a path under /_sww/.
ActionAdmin = "admin" ActionAdmin = "admin"
) )
@@ -56,14 +56,14 @@ const OffenceLimit = "limit"
// that broke the error burst. // that broke the error burst.
const LimitHitErrorBurst = "error_burst" const LimitHitErrorBurst = "error_burst"
// The servewaf_line a request line names, which says which of servewaf's two // The discriminator_line a request line names, which says which of
// lines about a question it is. // discriminator's two lines about a question it is.
const ( const (
// ServeWAFLineDecision is servewaf's line at its decision. // DiscriminatorLineDecision is discriminator's line at its decision.
ServeWAFLineDecision = "decision" DiscriminatorLineDecision = "decision"
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with // DiscriminatorLineReport is discriminator's line when rproxy's report
// the decision's fields and the report's together. // comes, with the decision's fields and the report's together.
ServeWAFLineReport = "report" DiscriminatorLineReport = "report"
) )
// timeLayout is RFC 3339 with milliseconds. // 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 //nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Line struct { type Line struct {
Type string `json:"type"` Type string `json:"type"`
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's // DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
// two lines about a question. // on discriminator's two lines about a question.
ServeWAFLine string `json:"servewaf_line,omitempty"` DiscriminatorLine string `json:"discriminator_line,omitempty"`
// The standard web log fields. Scheme is how the client reached rproxy, or // The standard web log fields. Scheme is how the client reached rproxy, or
// the trusted proxy in front of it. // the trusted proxy in front of it.
@@ -98,7 +98,7 @@ type Line struct {
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
// Request detail. RequestID is the id rproxy made for the request, or for a // 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 // X-Forwarded-For header as received. ClientGroup is the netblock the
// client is counted as. ASN, ASName and Country are the client's AS // client is counted as. ASN, ASName and Country are the client's AS
// number, AS name and country, as looked up. // number, AS name and country, as looked up.
@@ -118,8 +118,8 @@ type Line struct {
HasAuthorization bool `json:"has_authorization,omitempty"` HasAuthorization bool `json:"has_authorization,omitempty"`
HasCookie bool `json:"has_cookie,omitempty"` HasCookie bool `json:"has_cookie,omitempty"`
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or // Response detail, of discriminator's own answer. Aborted is true when
// the admin's tool, went away before the answer. // rproxy, or the admin's tool, went away before the answer.
ResponseContentType string `json:"response_content_type,omitempty"` ResponseContentType string `json:"response_content_type,omitempty"`
Aborted bool `json:"aborted,omitempty"` Aborted bool `json:"aborted,omitempty"`
@@ -128,7 +128,7 @@ type Line struct {
// WouldAction is, in observe mode, the action enforce mode would have // WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied, // taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied, ActionRateLimited, // ActionBanned, ActionCountryDenied, ActionRateLimited,
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge // ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// for the header limits. // for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// LimitPercent and LimitPercentSetting are, for a request the rate // 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. // if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"` DurationRequest float64 `json:"duration_request"`
DurationResponse float64 `json:"duration_response"` 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 "" // DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// when neither gave one. // when neither gave one.
Disposition string `json:"disposition,omitempty"` Disposition string `json:"disposition,omitempty"`
+2 -2
View File
@@ -18,7 +18,7 @@ const (
) )
// peerAddress is the address of the request's TCP peer, normally traefik, without // 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 { func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr) addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil { 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 // 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 // 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 // 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( func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix, peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr { ) netip.Addr {
@@ -11,15 +11,15 @@ import (
"net/http" "net/http"
"strings" "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. // It is far above any answer's.
const answerBodyMaxBytes = 1 << 20 const answerBodyMaxBytes = 1 << 20
// reportAnswerMaxBytes is the most of the answer to a report that is read, // 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 const reportAnswerMaxBytes = 4 << 10
var ( var (
@@ -27,21 +27,22 @@ var (
errReportRefused = errors.New("the report was answered") errReportRefused = errors.New("the report was answered")
) )
// servewafAnswer is what servewaf answered to a question. // discriminatorAnswer is what discriminator answered to a question.
type servewafAnswer struct { type discriminatorAnswer struct {
status int status int
header http.Header header http.Header
body []byte body []byte
} }
// noteAnswer notes what servewaf answered. An answer without a valid disposition // noteAnswer notes what discriminator answered. An answer without a valid disposition
// notes nothing, and no report is due for it: servewaf holds no decision to report // notes nothing, and no report is due for it: discriminator holds no decision to report
// on. // on.
func (rq *request) noteAnswer(answer *servewafAnswer) { func (rq *request) noteAnswer(answer *discriminatorAnswer) {
disposition := answer.header.Get(servewaf.DispositionHeader) disposition := answer.header.Get(discriminator.DispositionHeader)
switch disposition { switch disposition {
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose: case discriminator.DispositionAllow, discriminator.DispositionDeny,
discriminator.DispositionClose:
rq.disposition = disposition rq.disposition = disposition
rq.answered = true rq.answered = true
rq.asn = answer.header.Get(asnHeader) 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) { func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
switch { switch {
case ctx.Err() != nil: 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 // failed, and nothing is noted. This panic has Go's server close the
// connection without an answer, and log nothing; the deferred finish still // connection without an answer, and log nothing; the deferred finish still
// runs. // runs.
panic(http.ErrAbortHandler) panic(http.ErrAbortHandler)
case errors.Is(err, errCannotPassOn): 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) rq.answer(http.StatusBadGateway)
default: default:
// servewaf cannot be connected to, does not answer at all, or does not answer // discriminator cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past // within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets // SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503 // 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. // 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.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true rq.byDefault = true
if rq.disposition == servewaf.DispositionAllow { if rq.disposition == discriminator.DispositionAllow {
rq.passToApp(ctx) rq.passToApp(ctx)
} else { } else {
rq.answer(http.StatusServiceUnavailable) 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 // whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away. // 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) ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
defer cancel() defer cancel()
@@ -94,7 +95,7 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
return nil, err return nil, err
} }
res, err := rq.h.serveWAFTransport.RoundTrip(question) res, err := rq.h.discriminatorTransport.RoundTrip(question)
if err != nil { if err != nil {
return nil, fmt.Errorf("send the question: %w", err) 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 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: // it. It cannot send these as they came:
// - a target that does not start with /, or that starts with //; // - 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; // - a Host holding an IPv6 zone, which it removes;
// - more than one User-Agent, of which it sends the first; // - 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 // - an empty User-Agent, which it leaves out, the one case that still goes to
// servewaf. // discriminator.
func canBeAsked(r *http.Request) bool { func canBeAsked(r *http.Request) bool {
target, host := r.RequestURI, r.Host target, host := r.RequestURI, r.Host
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(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.TransferEncoding = in.TransferEncoding
question.Header = in.Header.Clone() question.Header = in.Header.Clone()
question.Header.Set(servewaf.ClientHeader, rq.client.String()) question.Header.Set(discriminator.ClientHeader, rq.client.String())
question.Header.Set(servewaf.PeerHeader, rq.peer.String()) question.Header.Set(discriminator.PeerHeader, rq.peer.String())
question.Header.Del(servewaf.ContentLengthHeader) question.Header.Del(discriminator.ContentLengthHeader)
announced := in.Header.Get("Content-Length") announced := in.Header.Get("Content-Length")
if announced != "" { if announced != "" {
question.Header.Set(servewaf.ContentLengthHeader, announced) question.Header.Set(discriminator.ContentLengthHeader, announced)
} }
question.Header.Set(requestIDHeader, rq.id) question.Header.Set(requestIDHeader, rq.id)
@@ -190,13 +191,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
return question, nil return question, nil
} }
// answerWith gives the visitor servewaf's answer, less its disposition. // answerWith gives the visitor discriminator's answer, less its disposition.
func (rq *request) answerWith(answer *servewafAnswer) { func (rq *request) answerWith(answer *discriminatorAnswer) {
rq.prepareOwnAnswer(rq.requestDeadline()) rq.prepareOwnAnswer(rq.requestDeadline())
header := rq.out.Header() header := rq.out.Header()
maps.Copy(header, answer.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 // 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. // 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) 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 // visitor does not wait for it. The report goes on after ctx, the request's own
// context, has ended. // 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() h.reportsMu.Lock()
defer h.reportsMu.Unlock() 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. // 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) body, err := json.Marshal(report)
if err != nil { if err != nil {
return fmt.Errorf("encode the report: %w", err) 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") req.Header.Set("Content-Type", "application/json")
res, err := h.serveWAFTransport.RoundTrip(req) res, err := h.discriminatorTransport.RoundTrip(req)
if err != nil { if err != nil {
return fmt.Errorf("send the report: %w", err) return fmt.Errorf("send the report: %w", err)
} }
+31 -31
View File
@@ -16,9 +16,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "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. // 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() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
@@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
func TestADenyOutranksTheBodySizeLimit(t *testing.T) { func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: "1K", maxRequestSize: "1K",
@@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
got := visit(t, newRequest(t, http.MethodPost, addr, "/", got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, 1025)))) bytes.NewReader(make([]byte, 1025))))
if got.status != http.StatusForbidden { 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 var asked atomic.Bool
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
asked.Store(true) asked.Store(true)
greet(w, r) greet(w, r)
@@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Run("announced, is refused", func(t *testing.T) { t.Run("announced, is refused", func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large))) w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
_, _ = io.WriteString(w, 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.Run("not announced, is cut off", func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, large[:1]) _, _ = io.WriteString(w, large[:1])
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
@@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
const announced = "2048" const announced = "2048"
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, announced) w.Header().Set(contentLengthHeader, announced)
}) })
@@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
t.Run(setting, func(t *testing.T) { t.Run(setting, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
setting: "off", setting: "off",
@@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader("a body")))) 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) 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "500", maxRequestTime: "500",
@@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
} }
if len(standIn.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("discriminator was asked")
} }
standIn.wantNoReport(t) standIn.wantNoReport(t)
@@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
} }
if len(standIn.questions) != 0 { 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
} }
if len(standIn.questions) != 0 { 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 announced = 100 * questionBodyMaxBytes
) )
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
@@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
name, target string name, target string
status int status int
}{ }{
{"servewaf's deny", "/", http.StatusForbidden}, {"discriminator's deny", "/", http.StatusForbidden},
{"rproxy's own 400", "x:admin", http.StatusBadRequest}, {"rproxy's own 400", "x:admin", http.StatusBadRequest},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{ _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
@@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000", maxRequestTime: "3000",
@@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
writeSize = 32 << 10 writeSize = 32 << 10
) )
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t), addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
map[string]string{maxRequestTime: "3000"}) map[string]string{maxRequestTime: "3000"})
@@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Parallel() t.Parallel()
release := make(chan struct{}) release := make(chan struct{})
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
<-release <-release
}) })
@@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
const start = "the start" const start = "the start"
release := make(chan struct{}) release := make(chan struct{})
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, start) _, _ = io.WriteString(w, start)
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
@@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
writeSize = 32 << 10 writeSize = 32 << 10
) )
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
part := make([]byte, writeSize) part := make([]byte, writeSize)
@@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/") got := get(t, addr, "/")
@@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) { func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting) _, _ = io.WriteString(w, greeting)
}) })
@@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
t.Parallel() t.Parallel()
working := make(chan struct{}) working := make(chan struct{})
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
close(working) close(working)
<-r.Context().Done() <-r.Context().Done()
@@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
const answer = "the end of a long answer" 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) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body) _, _ = io.Copy(io.Discard, r.Body)
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
@@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
{"a deny", post, http.StatusForbidden}, {"a deny", post, http.StatusForbidden},
{ {
"the health check", "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, http.StatusOK,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
// The time limit is long enough for a loaded host to read the first part of // The time limit is long enough for a loaded host to read the first part of
// the body before it runs out. // the body before it runs out.
+35 -34
View File
@@ -12,11 +12,11 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/discriminator"
) )
// questionBodyMaxBytes is the most of a body a question carries. // 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. // chunked is the encoding of a body whose length the visitor did not announce.
const chunked = "chunked" const chunked = "chunked"
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) 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(forwardedForHeader, "198.51.100.1")
req.Header.Set(forwardedProtoHeader, "gopher") req.Header.Set(forwardedProtoHeader, "gopher")
req.Header.Set(requestIDHeader, "the-visitor's") req.Header.Set(requestIDHeader, "the-visitor's")
req.Header.Set(servewaf.ClientHeader, "198.51.100.2") req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
req.Header.Set(servewaf.PeerHeader, "198.51.100.3") req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
req.Header.Set(servewaf.ContentLengthHeader, "12345") req.Header.Set(discriminator.ContentLengthHeader, "12345")
// Go's client then sends no User-Agent, so that one in the question would be // Go's client then sends no User-Agent, so that one in the question would be
// Go's own. // Go's own.
req.Header["User-Agent"] = nil req.Header["User-Agent"] = nil
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
want.Set(forwardedForHeader, "198.51.100.1") want.Set(forwardedForHeader, "198.51.100.1")
want.Set(forwardedProtoHeader, "http") want.Set(forwardedProtoHeader, "http")
want.Set(requestIDHeader, asked.header.Get(requestIDHeader)) want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
want.Set(servewaf.ClientHeader, localhost) want.Set(discriminator.ClientHeader, localhost)
want.Set(servewaf.PeerHeader, localhost) want.Set(discriminator.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) { if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf("the question's headers are %v, want %v", asked.header, want) 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
forwarded := make(chan http.Header, 1) forwarded := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Header.Clone() forwarded <- r.Header.Clone()
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
asked := standIn.question(t) asked := standIn.question(t)
if asked.header.Get(servewaf.ClientHeader) != tc.client || if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(servewaf.PeerHeader) != localhost || asked.header.Get(discriminator.PeerHeader) != localhost ||
asked.header.Get(forwardedForHeader) != visitor || asked.header.Get(forwardedForHeader) != visitor ||
asked.header.Get(forwardedProtoHeader) != tc.proto { asked.header.Get(forwardedProtoHeader) != tc.proto {
t.Errorf("the question's headers are %v", asked.header) 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8", trustedProxies: localhost + "/32,10.0.0.0/8",
@@ -167,7 +167,8 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
req.Header[forwardedForHeader] = tc.header req.Header[forwardedForHeader] = tc.header
wantGreeting(t, visit(t, req)) 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) t.Errorf("the question names %s, want %s", got, tc.client)
} }
}) })
@@ -182,7 +183,7 @@ func randomBytes(n int) []byte {
return body return body
} }
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) { func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel() t.Parallel()
for _, size := range []int{ for _, size := range []int{
@@ -197,7 +198,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel() t.Parallel()
body := randomBytes(size) body := randomBytes(size)
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
received := make(chan []byte, 1) received := make(chan []byte, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
whole, _ := io.ReadAll(r.Body) whole, _ := io.ReadAll(r.Body)
@@ -243,7 +244,7 @@ func wantQuestionBody(
len(want)) len(want))
} }
values := asked.header.Values(servewaf.ContentLengthHeader) values := asked.header.Values(discriminator.ContentLengthHeader)
if announced { if announced {
if !slices.Equal(values, []string{strconv.Itoa(size)}) || if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
@@ -264,7 +265,7 @@ func wantQuestionBody(
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -275,7 +276,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
strings.Repeat("x", questionBodyMaxBytes)) strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusForbidden { 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 { 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.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
if status := readStatus(t, conn); status != http.StatusForbidden { 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) asked := standIn.question(t)
@@ -334,7 +335,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
@@ -353,7 +354,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
} }
if len(standIn.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("discriminator was asked")
} }
standIn.wantNoReport(t) standIn.wantNoReport(t)
@@ -361,7 +362,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
} }
} }
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
t.Parallel() t.Parallel()
for _, method := range []string{ for _, method := range []string{
@@ -372,7 +373,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
t.Parallel() t.Parallel()
reached := make(chan string, 1) reached := make(chan string, 1)
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
reached <- r.Method reached <- r.Method
}) })
@@ -384,7 +385,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
} }
if asked := standIn.question(t); asked.method != method { 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 { 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() t.Parallel()
const host = "Host: site.example\r\n\r\n" 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.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -434,7 +435,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
} }
if len(standIn.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("discriminator was asked")
} }
standIn.wantNoReport(t) standIn.wantNoReport(t)
@@ -442,12 +443,12 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
} }
} }
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) { func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
t.Parallel() t.Parallel()
const host = "a%b" const host = "a%b"
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -468,7 +469,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
const want = "http" const want = "http"
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
@@ -497,7 +498,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{ _, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
@@ -510,8 +511,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
asked := standIn.question(t) asked := standIn.question(t)
if asked.header.Get(servewaf.ClientHeader) != tc.client || if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(servewaf.PeerHeader) != tc.peer { asked.header.Get(discriminator.PeerHeader) != tc.peer {
t.Errorf("the question's headers are %v", asked.header) t.Errorf("the question's headers are %v", asked.header)
} }
+11 -11
View File
@@ -16,16 +16,16 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "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 // flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
// soon as it arrives. // soon as it arrives.
const flushAfterEachWrite time.Duration = -1 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. // send a higher one, up to 999, and the visitor gets that as it is.
const reportStatusMax = 599 const reportStatusMax = 599
@@ -49,14 +49,14 @@ type request struct {
peerTrusted bool peerTrusted bool
start time.Time 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. // or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string 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. // and byDefault when SWRP_DEFAULT_DISPOSITION did.
answered bool answered bool
byDefault 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. // app.
asn, country string 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 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 // visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part // without an answer. A body over 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 { func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil { if rq.body == nil {
return true return true
} }
_ = rq.rc.SetReadDeadline(rq.requestDeadline()) _ = 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 // 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. // the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{}) _ = 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, // rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set, // sent to 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 // 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) { func (rq *request) rewrite(pr *httputil.ProxyRequest) {
upstream := rq.h.config.UpstreamURL upstream := rq.h.config.UpstreamURL
pr.Out.URL.Scheme = upstream.Scheme 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. // 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 // 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 // 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) { func (rq *request) send(status int, body []byte) {
if rq.in.Method != http.MethodHead { if rq.in.Method != http.MethodHead {
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body))) 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. // answered the question, sends the report. ctx is the request's own context.
func (rq *request) finish(ctx context.Context) { func (rq *request) finish(ctx context.Context) {
rq.stopTimers() rq.stopTimers()
@@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) {
return return
} }
report := &servewaf.Report{ report := &discriminator.Report{
RequestID: rq.id, RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax), Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes, RequestBytes: requestBytes,
+32 -31
View File
@@ -1,10 +1,10 @@
// Package rproxy is the reverse proxy in front of one app that asks servewaf // Package rproxy is the reverse proxy in front of one app that asks discriminator
// about each request. It passes a request servewaf allows to the app and the // 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 // 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 // discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
// decides only when servewaf cannot be connected to, does not answer at all, or // decides only when discriminator cannot be connected to, does not answer at all, or
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a // does not answer within 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 // sent it. It keeps nothing between requests and never touches disk. "rproxy" in
// SPEC.md says what it does. // SPEC.md says what it does.
package rproxy package rproxy
@@ -22,10 +22,10 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "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. // requests.
const ( const (
idleConns = 100 idleConns = 100
@@ -45,9 +45,9 @@ const (
idleTimeout = 120 * time.Second 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. // never reaches the app.
const adminPrefix = "/_smallwebwaf/" const adminPrefix = "/_sww/"
// allowedMethods lists the methods rproxy passes on, as an Allow header gives // allowedMethods lists the methods rproxy passes on, as an Allow header gives
// them. It answers any other method 405 itself. // them. It answers any other method 405 itself.
@@ -62,14 +62,14 @@ type Params struct {
ProcessLog *slog.Logger ProcessLog *slog.Logger
} }
// Server is the server `smallwebwaf rproxy` runs. // Server is the server `sww rproxy` runs.
type Server struct { type Server struct {
*http.Server *http.Server
h *handler 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 // a request line and headers over requestHeaderMaxBytes, with 431, closes a
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while // connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
// the headers arrive. The handler applies the time and size limits from then // 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 backend := params.Config.WAFBackend
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
h := &handler{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
processLog: params.ProcessLog, processLog: params.ProcessLog,
errorLog: errorLog, errorLog: errorLog,
transport: newTransport(), transport: newTransport(),
serveWAFTransport: newServeWAFTransport(), discriminatorTransport: newDiscriminatorTransport(),
reportURL: (&url.URL{ reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath, Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
}).String(), }).String(),
} }
@@ -120,10 +120,10 @@ type handler struct {
requestLog io.Writer requestLog io.Writer
processLog *slog.Logger processLog *slog.Logger
errorLog *log.Logger errorLog *log.Logger
// transport carries requests to the app, and serveWAFTransport questions and // transport carries requests to the app, and discriminatorTransport questions and
// reports to servewaf. // reports to discriminator.
transport http.RoundTripper transport http.RoundTripper
serveWAFTransport http.RoundTripper discriminatorTransport http.RoundTripper
// reportURL is where the reports go. // reportURL is where the reports go.
reportURL string reportURL string
// reports waits for the reports being sent. reportsMu guards starting one // 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 // 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 // header, or a Connection header other than close or keep-alive, and that would
// read as servewaf giving no answer. // read as discriminator giving no answer.
func newServeWAFTransport() *http.Transport { func newDiscriminatorTransport() *http.Transport {
transport := newTransport() transport := newTransport()
transport.Protocols = new(http.Protocols) transport.Protocols = new(http.Protocols)
transport.Protocols.SetHTTP1(true) transport.Protocols.SetHTTP1(true)
@@ -158,15 +159,15 @@ func newServeWAFTransport() *http.Transport {
} }
// ServeHTTP handles one request: it answers the health check itself, refuses a // 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 // 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. // it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.finish(r.Context()) 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() rq.answerHealth()
return return
@@ -199,9 +200,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq.noteAnswer(answer) rq.noteAnswer(answer)
switch rq.disposition { switch rq.disposition {
case servewaf.DispositionAllow: case discriminator.DispositionAllow:
rq.passToApp(r.Context()) rq.passToApp(r.Context())
case servewaf.DispositionClose: case discriminator.DispositionClose:
// This panic has Go's server close the connection without an answer, // This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs. // and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler) 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 // 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 gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context. // request's own context.
func (rq *request) passToApp(ctx context.Context) { func (rq *request) passToApp(ctx context.Context) {
+108 -103
View File
@@ -19,9 +19,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -54,27 +54,27 @@ const (
// The headers the tests set and look at. // The headers the tests set and look at.
const ( const (
dispositionHeader = servewaf.DispositionHeader dispositionHeader = discriminator.DispositionHeader
forwardedForHeader = "X-Forwarded-For" forwardedForHeader = "X-Forwarded-For"
forwardedProtoHeader = "X-Forwarded-Proto" forwardedProtoHeader = "X-Forwarded-Proto"
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
asnHeader = "X-Client-Asn" asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country" countryHeader = "X-Client-Country"
contentLengthHeader = "Content-Length" contentLengthHeader = "Content-Length"
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny. // fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
fromServeWAFHeader = "X-From-Servewaf" fromDiscriminatorHeader = "X-From-Servewaf"
// addedHeaderValue is the value of a header a test adds to an answer, to see it // addedHeaderValue is the value of a header a test adds to an answer, to see it
// reach the visitor. // reach the visitor.
addedHeaderValue = "yes" addedHeaderValue = "yes"
) )
// What the stand-in for servewaf answers. // What the stand-in for discriminator answers.
const ( const (
dispositionAllow = servewaf.DispositionAllow dispositionAllow = discriminator.DispositionAllow
dispositionDeny = servewaf.DispositionDeny dispositionDeny = discriminator.DispositionDeny
dispositionClose = servewaf.DispositionClose dispositionClose = discriminator.DispositionClose
// deniedBody is the body of a deny. // deniedBody is the body of a deny.
deniedBody = "refused by servewaf" deniedBody = "refused by discriminator"
) )
// output collects what rproxy writes on stdout. // output collects what rproxy writes on stdout.
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
return len(p), nil 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 { type question struct {
method, target, host string method, target, host string
header http.Header header http.Header
@@ -167,26 +167,26 @@ type question struct {
transferEncoding []string transferEncoding []string
} }
// serveWAF is a stand-in for servewaf that answers each question as answer // Discriminator is a stand-in for discriminator that answers each question as
// does, and keeps the questions and the reports it gets. // answer does, and keeps the questions and the reports it gets.
type serveWAF struct { type Discriminator struct {
*httptest.Server *httptest.Server
questions chan question questions chan question
reports chan servewaf.Report reports chan discriminator.Report
} }
// startServeWAF starts a stand-in for servewaf. // startDiscriminator starts a stand-in for discriminator.
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF { func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
t.Helper() t.Helper()
s := &serveWAF{ s := &Discriminator{
questions: make(chan question, 10), questions: make(chan question, 10),
reports: make(chan servewaf.Report, 10), reports: make(chan discriminator.Report, 10),
} }
s.Server = httptest.NewServer(http.HandlerFunc( s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(servewaf.ClientHeader) == "" { if r.Header.Get(discriminator.ClientHeader) == "" {
s.takeReport(t, w, r) s.takeReport(t, w, r)
return return
@@ -209,14 +209,17 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
return s return s
} }
// takeReport keeps a report and answers it as servewaf does. // takeReport keeps a report and answers it as discriminator does.
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) { func (s *Discriminator) takeReport(
t *testing.T, w http.ResponseWriter, r *http.Request,
) {
t.Helper() t.Helper()
var report servewaf.Report var report discriminator.Report
err := json.NewDecoder(r.Body).Decode(&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) 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. // 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() t.Helper()
select { select {
@@ -237,7 +240,7 @@ func (s *serveWAF) wantNoReport(t *testing.T) {
} }
// report waits for a report. // 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() t.Helper()
select { select {
@@ -246,12 +249,12 @@ func (s *serveWAF) report(t *testing.T) servewaf.Report {
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("no report") t.Fatal("no report")
return servewaf.Report{} return discriminator.Report{}
} }
} }
// question waits for a question. // question waits for a question.
func (s *serveWAF) question(t *testing.T) question { func (s *Discriminator) question(t *testing.T) question {
t.Helper() t.Helper()
select { 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. // 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, t *testing.T, answer, report http.HandlerFunc,
) string { ) string {
t.Helper() t.Helper()
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(servewaf.ClientHeader) != "" { if r.Header.Get(discriminator.ClientHeader) != "" {
answer(w, r) answer(w, r)
return 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. // deny answers a question with deny, status 403 and a body of its own.
func deny(w http.ResponseWriter, _ *http.Request) { func deny(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny) w.Header().Set(dispositionHeader, dispositionDeny)
w.Header().Set(fromServeWAFHeader, addedHeaderValue) w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, deniedBody) _, _ = io.WriteString(w, deniedBody)
} }
@@ -316,7 +320,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, greeting) _, _ = 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 // the settings in env on top of the defaults. It returns where rproxy listens
// and what it writes. // and what it writes.
func startRProxy( func startRProxy(
@@ -498,7 +502,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) { func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
t.Parallel() 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(asnHeader, "AS64496")
w.Header().Set(countryHeader, "DE") w.Header().Set(countryHeader, "DE")
allow(w, r) allow(w, r)
@@ -557,7 +561,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
} }
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
seen := make(chan http.Header, 1) seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone() seen <- r.Header.Clone()
@@ -602,7 +606,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
seen := make(chan http.Header, 1) seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone() seen <- r.Header.Clone()
@@ -641,7 +645,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>") _, _ = io.WriteString(w, "<html>")
@@ -663,19 +667,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
} }
} }
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) { func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
name string name string
backend func(t *testing.T) string backend func(t *testing.T) string
}{ }{
{"servewaf allows", func(t *testing.T) string { {"discriminator allows", func(t *testing.T) string {
t.Helper() 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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -701,10 +705,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
} }
} }
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
@@ -712,8 +716,8 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusForbidden || got.body != deniedBody || if got.status != http.StatusForbidden || got.body != deniedBody ||
got.header.Get(fromServeWAFHeader) != addedHeaderValue { got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want servewaf's answer", got) t.Errorf("answered %+v, want discriminator's answer", got)
} }
if len(got.header.Values(dispositionHeader)) != 0 { if len(got.header.Values(dispositionHeader)) != 0 {
@@ -729,21 +733,21 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
line := out.requestLine(t) line := out.requestLine(t)
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil { 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() t.Parallel()
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusForbidden || if got.status != http.StatusForbidden ||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) { 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}, {"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest}, {"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.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
log := heldLog{release: make(chan struct{})} log := heldLog{release: make(chan struct{})}
standIn := startServeWAF(t, deny) standIn := startDiscriminator(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard) 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) { func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -817,7 +821,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel() 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.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -849,7 +853,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
t.Parallel() t.Parallel()
backend := startServeWAFAnsweringReports(t, backend := startDiscriminatorAnsweringReports(t,
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose) 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. // question as answer does, and returns its URL.
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string { func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string { return func(t *testing.T) string {
t.Helper() 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 { func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{ return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere }, "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() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -925,7 +929,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
}{ }{
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden}, {"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests}, {"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 500", "", "broken\n", http.StatusInternalServerError},
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable}, {"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
{"no header", "", "who knows\n", http.StatusOK}, {"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.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
if tc.disposition != "" { if tc.disposition != "" {
w.Header().Set(dispositionHeader, tc.disposition) w.Header().Set(dispositionHeader, tc.disposition)
} }
w.Header().Set(fromServeWAFHeader, addedHeaderValue) w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.WriteHeader(tc.status) w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body) _, _ = io.WriteString(w, tc.body)
}) })
@@ -953,8 +957,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != tc.status || got.body != tc.body || if got.status != tc.status || got.body != tc.body ||
got.header.Get(fromServeWAFHeader) != addedHeaderValue { got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want servewaf's answer", got) t.Errorf("answered %+v, want discriminator's answer", got)
} }
if len(got.header.Values(dispositionHeader)) != 0 { 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) t.Errorf("line %v, want status %d and no default", line, tc.status)
} }
if out.has("servewaf gave no answer") { if out.has("discriminator gave no answer") {
t.Error("rproxy warned that servewaf 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) { func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Parallel() 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) http.Error(w, "refused", http.StatusBadRequest)
}) })
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadRequest { 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 { if line := out.requestLine(t); line["disposition"] != nil {
@@ -997,7 +1001,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) { func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel() 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.Header()["Content-Type"] = nil
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "<html>broken") _, _ = io.WriteString(w, "<html>broken")
@@ -1015,7 +1019,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
const size = 1 << 20 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.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size)) _, _ = w.Write(make([]byte, size))
}) })
@@ -1023,15 +1027,16 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size { 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) 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 // answers it with text, as it is, and returns its URL. It can send what Go's
// server would not. // server would not.
func startServeWAFWriting(t *testing.T, text string) string { func startDiscriminatorWriting(t *testing.T, text string) string {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") 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 { "a status below 100": func(t *testing.T) string {
t.Helper() 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), "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) 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) { func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, overOneMiB(dispositionDeny)) standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, standIn.URL, nowhere, nil) addr, out := startRProxy(t, standIn.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway { 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() t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} { for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run("with the default "+disposition, func(t *testing.T) { t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}) })
app := startApp(t, func(http.ResponseWriter, *http.Request) { 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) t.Errorf("line %v, want status 0 and no disposition", line)
} }
if out.has("servewaf gave no answer") { if out.has("discriminator gave no answer") {
t.Error("rproxy warned that servewaf gave no answer") t.Error("rproxy warned that discriminator gave no answer")
} }
standIn.wantNoReport(t) standIn.wantNoReport(t)
@@ -1238,13 +1243,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
addr, out := startRProxy(t, nowhere, app.URL, nil) addr, out := startRProxy(t, nowhere, app.URL, nil)
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "servewaf gave no answer") out.waitFor(t, "discriminator gave no answer")
} }
func TestSendsNoReportWithoutAnAnswer(t *testing.T) { func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}) })
app := startApp(t, greet) app := startApp(t, greet)
@@ -1265,35 +1270,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
}) })
addr, _ := startRProxy(t, nowhere, app.URL, nil) addr, _ := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, "/_smallwebwaf/metrics") got := get(t, addr, "/_sww/metrics")
if got.status != http.StatusNotFound { if got.status != http.StatusNotFound {
t.Errorf("answered %+v, want 404", got) t.Errorf("answered %+v, want 404", got)
} }
} }
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) { func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil) 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" { if got.status != http.StatusOK || got.body != "ok\n" {
t.Errorf("answered %+v, want ok", got) t.Errorf("answered %+v, want ok", got)
} }
line := out.requestLine(t) 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) 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) addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
get(t, addr, servewaf.HealthPath) get(t, addr, discriminator.HealthPath)
if len(standIn.questions) != 0 { 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() t.Parallel()
release := make(chan struct{}) release := make(chan struct{})
backend := startServeWAFAnsweringReports(t, deny, backend := startDiscriminatorAnsweringReports(t, deny,
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
select { select {
case <-release: case <-release:
@@ -1336,16 +1341,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
_ = res.Body.Close() _ = res.Body.Close()
if res.StatusCode != http.StatusForbidden { 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() t.Parallel()
release := make(chan struct{}) release := make(chan struct{})
backend := startServeWAFAnsweringReports(t, allow, backend := startDiscriminatorAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
<-release <-release
@@ -1365,10 +1370,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
out.waitFor(t, "a report is dropped") out.waitFor(t, "a report is dropped")
} }
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) { func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
t.Parallel() t.Parallel()
backend := startServeWAFAnsweringReports(t, allow, backend := startDiscriminatorAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
}) })
@@ -1383,7 +1388,7 @@ func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) { func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
t.Parallel() t.Parallel()
backend := startServeWAFAnsweringReports(t, allow, switchAndHold) backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, map[string]string{ addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000", timeoutSetting: "60000",
@@ -1396,7 +1401,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) { func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out, server := startServer(t, standIn.URL, app.URL, nil) 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" const body = "the visitor's body"
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -1442,7 +1447,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
release := make(chan struct{}) release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(servewaf.ClientHeader) != "" { if r.Header.Get(discriminator.ClientHeader) != "" {
allow(w, r) allow(w, r)
return return
@@ -1497,7 +1502,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, echoAfterUpgrade) app := startApp(t, echoAfterUpgrade)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000", maxRequestTime: "2000",
@@ -1552,7 +1557,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
const goodbye = "sent after the visitor stopped sending\n" 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) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack() conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
@@ -1610,7 +1615,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
//nolint:paralleltest // see above //nolint:paralleltest // see above
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) { func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
closed := make(chan error, 1) closed := make(chan error, 1)
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack() conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
@@ -1681,7 +1686,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
const fromTraefik = "an-id-from-traefik" const fromTraefik = "an-id-from-traefik"
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
@@ -1711,7 +1716,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
body = "the visitor's body" body = "the visitor's body"
) )
standIn := startServeWAF(t, allow) standIn := startDiscriminator(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
+2 -2
View File
@@ -106,13 +106,13 @@ type Params struct {
// and no rule loaded. // and no rule loaded.
Enabled bool Enabled bool
// ProcessLog receives how many rules were read, and the error in a // 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 ProcessLog *slog.Logger
// Alerts receive a file_error alert for that error. // Alerts receive a file_error alert for that error.
Alerts *alerts.Queue 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. // from them. They are safe for concurrent use.
type Files struct { type Files struct {
params Params params Params
+1 -1
View File
@@ -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 // 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 { func get(t *testing.T, target string) *http.Request {
t.Helper() t.Helper()
+17 -17
View File
@@ -1,4 +1,4 @@
// Package state keeps smallwebwaf's state in JSON files in // Package state keeps sww's state in JSON files in
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires // SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires
// and docs/state-files.md lists them: // and docs/state-files.md lists them:
// bans.json holds the bans, clients.json each client's counters and // bans.json holds the bans, clients.json each client's counters and
@@ -8,7 +8,7 @@
// cooldowns, the hour under way, the alerts waiting for each destination // cooldowns, the hour under way, the alerts waiting for each destination
// and the anomaly counters. Load // and the anomaly counters. Load
// reads them at start, Watch takes in an admin's edit of one while // 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 // 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 // snapshot or to put in what a file holds, so that no request waits on
// the disk. // the disk.
@@ -44,7 +44,7 @@ import (
// version is the version of the files' format, the only one read. // version is the version of the files' format, the only one read.
const version = 1 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. // hold visitors' addresses.
const fileMode = 0o600 const fileMode = 0o600
@@ -81,7 +81,7 @@ type Params struct {
CounterInterval time.Duration CounterInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
// hold the state. Alerts also receive a file_error alert for an edit set // 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 Ledger *bans.Ledger
Limiter *ratelimit.Limiter Limiter *ratelimit.Limiter
GeoJS *lookup.GeoJS GeoJS *lookup.GeoJS
@@ -101,7 +101,7 @@ type Params struct {
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
// Files are the state files of a running smallwebwaf. // Files are the state files of a running sww.
type Files struct { type Files struct {
params Params params Params
@@ -110,7 +110,7 @@ type Files struct {
// it. // it.
mu sync.Mutex mu sync.Mutex
// sums are the SHA-256 sums of what each file held, by name, when // 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. // has been edited since.
sums map[string][sha256.Size]byte 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. // fails does not keep the others from being written.
func (f *Files) WriteAll() error { func (f *Files) WriteAll() error {
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON), 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 // 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 // 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 // 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 // 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. // 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 // 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 // the file. A file that cannot be read or does not parse is left for its
// next write. // next write.
func (f *Files) fileChanged(name string) { 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 // 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 // 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. // parse is neither counted nor logged, and takeIn's error returned.
func (f *Files) takeInEdit(name string, data []byte) error { func (f *Files) takeInEdit(name string, data []byte) error {
@@ -356,8 +356,8 @@ func (f *Files) read(name string) (int, error) {
} }
// readChanged returns what the state file name holds, and whether that // readChanged returns what the state file name holds, and whether that
// has changed since smallwebwaf last read or wrote the file, as it has // has changed since sww last read or wrote the file, as it has
// for a file smallwebwaf never read or wrote. A missing file has not // for a file sww never read or wrote. A missing file has not
// changed: it is written again at its next write. // changed: it is written again at its next write.
func (f *Files) readChanged(name string) ([]byte, bool, error) { func (f *Files) readChanged(name string) ([]byte, bool, error) {
path := filepath.Join(f.params.Dir, name) path := filepath.Join(f.params.Dir, name)
@@ -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 // 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 // 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 // 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, // start. An error names the file and, where the JSON decoder tells it,
// the line and column, or else the entry. // the line and column, or else the entry.
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { 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 return entries, nil
} }
// writeFile writes the state file name from what smallwebwaf holds. An // writeFile writes the state file name from what sww holds. An
// edit made since smallwebwaf last read or wrote the file is taken in // 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 // 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 // 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 // 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 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. // snapshot of the part that keeps that state.
func (f *Files) encode(name string) ([]byte, error) { func (f *Files) encode(name string) ([]byte, error) {
switch name { switch name {
@@ -965,7 +965,7 @@ func parse(path string, data []byte, file stateFile) error {
err := json.Unmarshal(data, &header) err := json.Unmarshal(data, &header)
if err == nil && header.Version != version { 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) errVersion, header.Version, version)
} }
+20 -20
View File
@@ -122,7 +122,7 @@ const filledAlertsJSON = `{
{ {
"event": "file_error", "event": "file_error",
"netblock": "", "netblock": "",
"file": "/var/lib/smallwebwaf/bans.json", "file": "/var/lib/sww/bans.json",
"sent": "2026-10-06T00:00:00Z", "sent": "2026-10-06T00:00:00Z",
"suppressed_repeats": 0 "suppressed_repeats": 0
}, },
@@ -169,7 +169,7 @@ const filledAlertsJSON = `{
"reason": "writing the state files failed", "reason": "writing the state files failed",
"detail": { "detail": {
"error": "no space left on device", "error": "no space left on device",
"file": "/var/lib/smallwebwaf/bans.json" "file": "/var/lib/sww/bans.json"
}, },
"suppressed_repeats": 0 "suppressed_repeats": 0
} }
@@ -1165,13 +1165,13 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
) )
got := scrape(t, params) got := scrape(t, params)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 1) wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 1)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofClients, 1) wantMetric(t, got, "sww_state_file_writes_total"+ofClients, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 0) wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 0)
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans, wantMetric(t, got, "sww_state_file_size_bytes"+ofBans,
float64(len(permanentBansJSON))) 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()) { if written < float64(time.Now().Add(-time.Hour).Unix()) {
t.Errorf("bans.json was last written at %v, not by that write", written) 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) got = scrape(t, params)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 2) wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 2)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 1) wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofClients, 0) wantMetric(t, got, "sww_state_file_write_failures_total"+ofClients, 0)
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans, wantMetric(t, got, "sww_state_file_size_bytes"+ofBans,
float64(len(permanentBansJSON))) float64(len(permanentBansJSON)))
} }
@@ -1486,7 +1486,7 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) {
"and the reason %q", written, err, reason) "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. // admin's too, and one made since the start.
watch(t, files, lines) watch(t, files, lines)
edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24")) edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24"))
@@ -1549,7 +1549,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
watch(t, files, lines) 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 // of clients.json, made after it and taken in, shows that it has been
// seen. // seen.
edit(t, dir, bansJSON, broken) edit(t, dir, bansJSON, broken)
@@ -1558,7 +1558,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON)
// The next write sets it aside, logged with where the error is, and // 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() err = files.WriteAll()
if err != nil { if err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
@@ -1613,7 +1613,7 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
wantTakenIn(t, lines, dir, bansJSON) wantTakenIn(t, lines, dir, bansJSON)
wantMetric(t, scrape(t, params), 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) { func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
@@ -1678,7 +1678,7 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
} }
wantMetric(t, scrape(t, params), 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) { func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
@@ -1854,7 +1854,7 @@ func fill(params state.Params) {
params.Alerts.Raise(alerts.Alert{ params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Reason: "writing the state files failed", Event: alerts.EventFileError, Reason: "writing the state files failed",
Detail: map[string]any{ 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{ 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 // 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. // fails the test if there is no such series.
func metric(t *testing.T, text, series string) float64 { func metric(t *testing.T, text, series string) float64 {
t.Helper() t.Helper()
@@ -2210,8 +2210,8 @@ func wantWriteFailed(t *testing.T, params state.Params, name string) {
got := scrape(t, params) got := scrape(t, params)
file := `{file="` + name + `",instance="app"}` file := `{file="` + name + `",instance="app"}`
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1) wantMetric(t, got, "sww_state_file_writes_total"+file, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_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 // wantMetric checks the value of series in text, the metrics, as metric
@@ -1,4 +1,4 @@
package smallwebwaf_test package sww_test
import ( import (
"bytes" "bytes"
@@ -14,14 +14,14 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/sww"
) )
// commandForMain, while set in the environment, has this test binary run Main // 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 // with the command it gives, in place of the tests, so that a test can run
// Main in a process of its own. // Main in a process of its own.
const commandForMain = "SMALLWEBWAF_TEST_COMMAND" const commandForMain = "SWW_TEST_COMMAND"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
command, set := os.LookupEnv(commandForMain) command, set := os.LookupEnv(commandForMain)
@@ -29,12 +29,12 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) 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() t.Parallel()
binary, err := os.Executable() binary, err := os.Executable()
@@ -50,7 +50,7 @@ func TestMainRunsServeWAF(t *testing.T) {
cmd.Stdout = out cmd.Stdout = out
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
commandForMain+"="+serveWAFCommand, commandForMain+"="+discriminatorCommand,
listenAddr+"="+localhost+":0", listenAddr+"="+localhost+":0",
stateDir+"="+t.TempDir(), stateDir+"="+t.TempDir(),
rulesDir+"="+t.TempDir(), rulesDir+"="+t.TempDir(),
@@ -67,7 +67,7 @@ func TestMainRunsServeWAF(t *testing.T) {
}) })
addr, _ := out.line(t, "msg", "starting")["address"].(string) 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) err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil { if err != nil {
@@ -108,7 +108,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
t.Errorf("exit status %d (%v), want 2", code, err) 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) { if !strings.Contains(stderr.String(), name) {
t.Errorf("stderr %q does not name %s", 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. // placed, checks the answer's status and disposition, and closes it.
func ask(t *testing.T, addr, path string, status int, disposition string) { func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Helper() t.Helper()
@@ -132,7 +132,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Fatalf("new request: %v", err) 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()) req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
@@ -147,16 +147,16 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
_ = res.Body.Close() _ = res.Body.Close()
if res.StatusCode != status || 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, 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 // send it requests at the address it listens on, stops it as SIGTERM does, and
// checks that it stopped in order. // 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() t.Helper()
ctx, stop := context.WithCancel(t.Context()) 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") out.line(t, "msg", "stopped")
} }
func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) { func TestDiscriminatorKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
t.Parallel() t.Parallel()
env := map[string]string{ env := map[string]string{
@@ -192,13 +192,13 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
rulesDir: filepath.Join("..", "..", "share", "rules.d"), rulesDir: filepath.Join("..", "..", "share", "rules.d"),
} }
runServeWAF(t, env, func(addr string) { runDiscriminator(t, env, func(addr string) {
ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow) ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow)
// The default rule file bans a probe for this. // 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) { runDiscriminator(t, env, func(addr string) {
ask(t, addr, "/", http.StatusForbidden, servewaf.DispositionDeny) ask(t, addr, "/", http.StatusForbidden, discriminator.DispositionDeny)
}) })
} }
@@ -1,4 +1,4 @@
package smallwebwaf package sww
import ( import (
"context" "context"
@@ -11,7 +11,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/discriminator"
) )
// healthCheckTimeout bounds the whole health check. // healthCheckTimeout bounds the whole health check.
@@ -19,13 +19,13 @@ const healthCheckTimeout = 5 * time.Second
var errHealthEndpoint = errors.New("the health endpoint answered") var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In servewaf's container, where // HealthCheck is the container's health check. In discriminator's container, where
// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health // SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container, // 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 // 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 // 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, // 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 // returns 1. It reads no other setting, nor a file that another names, so neither
// can fail it. // can fail it.
// args are the arguments after `healthcheck`; it takes none, and given // args are the arguments after `healthcheck`; it takes none, and given
@@ -36,7 +36,7 @@ func HealthCheck(
) int { ) int {
if len(args) > 0 { if len(args) > 0 {
_, _ = fmt.Fprintf(stderr, _, _ = fmt.Fprintf(stderr,
"smallwebwaf healthcheck: unexpected argument %q\n", args[0]) "sww healthcheck: unexpected argument %q\n", args[0])
return 1 return 1
} }
@@ -49,8 +49,8 @@ func HealthCheck(
switch { switch {
case err != nil: case err != nil:
err = fmt.Errorf("invalid setting: %w", err) err = fmt.Errorf("invalid setting: %w", err)
case command == "servewaf": case command == "discriminator":
err = checkServeWAF(ctx, lookupEnv) err = checkDiscriminator(ctx, lookupEnv)
default: default:
err = checkRProxyAndApp(ctx, lookupEnv) err = checkRProxyAndApp(ctx, lookupEnv)
} }
@@ -64,16 +64,18 @@ func HealthCheck(
return 0 return 0
} }
// checkServeWAF asks servewaf's health endpoint. // checkDiscriminator asks discriminator's health endpoint.
func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error { func checkDiscriminator(
listenAddr, err := config.ServeWAFListenAddr(lookupEnv) ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, err := config.DiscriminatorListenAddr(lookupEnv)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
err = askHealthEndpoint(ctx, listenAddr) err = askHealthEndpoint(ctx, listenAddr)
if err != nil { if err != nil {
return fmt.Errorf("ask servewaf: %w", err) return fmt.Errorf("ask discriminator: %w", err)
} }
return nil return nil
@@ -107,7 +109,7 @@ func checkRProxyAndApp(
// listenAddr, which the settings have checked has one. // listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error { func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr) _, 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
if err != nil { if err != nil {
@@ -1,4 +1,4 @@
package smallwebwaf package sww
import ( import (
"net/url" "net/url"
@@ -1,4 +1,4 @@
package smallwebwaf_test package sww_test
import ( import (
"bytes" "bytes"
@@ -12,18 +12,18 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/sww"
) )
const ( const (
// commandSetting says which command a container runs, and which the health // commandSetting says which command a container runs, and which the health
// check checks. // check checks.
commandSetting = "SMALLWEBWAF_COMMAND" commandSetting = "SWW_COMMAND"
// serveWAFCommand is the command that runs servewaf. // discriminatorCommand is the command that runs discriminator.
serveWAFCommand = "servewaf" discriminatorCommand = "discriminator"
) )
func TestHealthCheckInServeWAFsContainer(t *testing.T) { func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
t.Parallel() t.Parallel()
ctx, stop := context.WithCancel(t.Context()) ctx, stop := context.WithCancel(t.Context())
@@ -47,7 +47,7 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) {
// The container's settings: an address to listen on with an empty // The container's settings: an address to listen on with an empty
// host part, which the health check asks at 127.0.0.1. // host part, which the health check asks at 127.0.0.1.
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, listenAddr: ":" + port, commandSetting: discriminatorCommand, listenAddr: ":" + port,
}, 0, "") }, 0, "")
// The health check reads that setting alone, here given as a file: a removed // The health check reads that setting alone, here given as a file: a removed
@@ -59,7 +59,7 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) {
{"SWWAF_MODE", "neither"}, {"SWWAF_MODE", "neither"},
} { } {
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, commandSetting: discriminatorCommand,
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"), listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
other.name: other.value, other.name: other.value,
}, 0, "") }, 0, "")
@@ -68,10 +68,10 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) {
// Listening on port 0 takes any free port, so nothing listens on port 0 // Listening on port 0 takes any free port, so nothing listens on port 0
// itself. // itself.
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, listenAddr: ":0", commandSetting: discriminatorCommand, listenAddr: ":0",
}, 1, "unhealthy: ask servewaf: ") }, 1, "unhealthy: ask discriminator: ")
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, listenAddr: "8080", commandSetting: discriminatorCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ") }, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
stop() stop()
@@ -90,7 +90,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
defer app.Close() defer app.Close()
// Nothing listens at the backend. The health check asks rproxy and the app, // 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) site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://")) _, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
settings := func(more ...string) map[string]string { 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, ""), 0, "")
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "") wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
// SMALLWEBWAF_COMMAND cannot be given as a file. // SWW_COMMAND cannot be given as a file.
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")), wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "discriminator\n")),
0, "") 0, "")
// Another setting does not fail it, even an invalid one, and neither does // Another setting does not fail it, even an invalid one, and neither does
@@ -136,8 +136,8 @@ func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
t.Parallel() t.Parallel()
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1, wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
"unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+ "unhealthy: invalid setting: SWW_COMMAND: "+
"\"healthcheck\" is not rproxy or servewaf\n") "\"healthcheck\" is not rproxy or discriminator\n")
} }
func TestHealthCheckRefusesAnArgument(t *testing.T) { func TestHealthCheckRefusesAnArgument(t *testing.T) {
@@ -149,9 +149,9 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
return "", false 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 { if got != 1 || stderr.String() != want {
t.Errorf("health check returned %d and wrote %q, want 1 and %q", t.Errorf("health check returned %d and wrote %q, want 1 and %q",
got, stderr.String(), want) 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 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] value, ok := env[name]
return value, ok return value, ok
@@ -1,4 +1,4 @@
package smallwebwaf package sww
import ( import (
"context" "context"
@@ -13,7 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rproxy" "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 // requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It // shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process // returns the process's exit status, 1 when rproxy cannot start. Its process
@@ -1,4 +1,4 @@
package smallwebwaf_test package sww_test
import ( import (
"bufio" "bufio"
@@ -19,18 +19,18 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/sww"
) )
const ( const (
// wafBackend is the setting that names servewaf to rproxy. // wafBackend is the setting that names discriminator to rproxy.
wafBackend = "SWRP_WAF_BACKEND" wafBackend = "SWRP_WAF_BACKEND"
// rproxyListenAddr is the setting that says where rproxy listens. // rproxyListenAddr is the setting that says where rproxy listens.
rproxyListenAddr = "SWRP_LISTEN_ADDR" rproxyListenAddr = "SWRP_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is. // rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWRP_UPSTREAM_URL" 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" rproxyTimeout = "SWRP_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for. // bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env" bannedProbe = "/.env"
@@ -38,10 +38,10 @@ const (
otherClient = "198.51.100.7" 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. // done, and returns its exit status.
func runRProxy(ctx context.Context, env map[string]string, out *output) int { 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, Version: testVersion,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
value, ok := env[name] 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 // 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. // 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) { 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 return "http://" + addr, out
} }
// startServeWAF runs servewaf with the settings in env, and returns its address // startDiscriminator runs discriminator with the settings in env, and returns
// and URL, what it writes, and what stops it, which checks that it stopped in // its address and URL, what it writes, and what stops it, which checks that it
// order. // stopped in order.
func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) { func startDiscriminator(t *testing.T, env map[string]string) (string, *output, func()) {
t.Helper() t.Helper()
ctx, stop := context.WithCancel(t.Context()) ctx, stop := context.WithCancel(t.Context())
@@ -113,17 +113,17 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { 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): 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 // discriminatorSettings are discriminator's settings with the default rule
// at any free port. // file, to listen at any free port.
func serveWAFSettings(t *testing.T) map[string]string { func discriminatorSettings(t *testing.T) map[string]string {
t.Helper() t.Helper()
return map[string]string{ return map[string]string{
@@ -136,7 +136,7 @@ func serveWAFSettings(t *testing.T) map[string]string {
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) { func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
t.Parallel() t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
defer stop() defer stop()
backend := "http://" + addr backend := "http://" + addr
@@ -151,30 +151,32 @@ func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
wantStatus(t, second+"/", placed, http.StatusForbidden) wantStatus(t, second+"/", placed, http.StatusForbidden)
wantStatus(t, second+"/", otherClient, http.StatusOK) 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) 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) t.Errorf("rproxy's line is %v", line)
} }
id, _ := line["request_id"].(string) id, _ := line["request_id"].(string)
decision := serveWAFLine(t, serveWAFOut, id, "decision") decision := discriminatorLine(t, discriminatorOut, id, "decision")
report := serveWAFLine(t, serveWAFOut, id, "report") report := discriminatorLine(t, discriminatorOut, id, "report")
if decision["host"] != line["host"] || decision["client_ip"] != placed || if decision["host"] != line["host"] || decision["client_ip"] != placed ||
report["status"] != float64(http.StatusForbidden) { 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. // discriminatorLine waits for discriminator's line with request id and
func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any { // discriminator_line kind.
func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]any {
t.Helper() t.Helper()
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
line := out.findLine(t, func(fields map[string]any) bool { 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 { if line != nil {
return line return line
@@ -188,11 +190,11 @@ func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
return nil return nil
} }
func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T) {
t.Parallel() t.Parallel()
env := serveWAFSettings(t) env := discriminatorSettings(t)
addr, _, stop := startServeWAF(t, env) addr, _, stop := startDiscriminator(t, env)
site, out := startRProxy(t, "http://"+addr, startApp(t)) site, out := startRProxy(t, "http://"+addr, startApp(t))
// A visitor who keeps asking throughout, and is never refused. // 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) wantStatus(t, site+"/", placed, http.StatusForbidden)
stop() 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) wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop") line := out.line(t, "path", "/after-stop")
if line["disposition"] != servewaf.DispositionAllow || if line["disposition"] != discriminator.DispositionAllow ||
line["default_disposition"] != true { line["default_disposition"] != true {
t.Errorf("rproxy's line is %v", line) 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. // Back at its address, with its state files, it applies the ban again.
restarted := maps.Clone(env) restarted := maps.Clone(env)
restarted[listenAddr] = addr restarted[listenAddr] = addr
_, _, stop = startServeWAF(t, restarted) _, _, stop = startDiscriminator(t, restarted)
defer stop() defer stop()
@@ -306,10 +308,10 @@ func sendRaw(t *testing.T, site, request string) int {
return res.StatusCode return res.StatusCode
} }
func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
t.Parallel() t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
defer stop() defer stop()
site, _ := startRProxy(t, "http://"+addr, startApp(t)) site, _ := startRProxy(t, "http://"+addr, startApp(t))
@@ -318,7 +320,7 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
const smuggledID = "smuggled-request" const smuggledID = "smuggled-request"
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" + 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" "X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\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) 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 return fields["request_id"] == smuggledID
}) })
if line != nil { 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() t.Parallel()
addr, _, stop := startServeWAF(t, serveWAFSettings(t)) addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
defer stop() defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t)) site, out := startRProxy(t, "http://"+addr, startApp(t))
@@ -369,7 +371,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
const zoned = "2001:db8::1%eth0" const zoned = "2001:db8::1%eth0"
addr, _, stop := startServeWAF(t, serveWAFSettings(t)) addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
defer stop() defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t)) 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() t.Parallel()
const status = 799 const status = 799
@@ -395,7 +397,7 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
})) }))
t.Cleanup(app.Close) t.Cleanup(app.Close)
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
defer stop() defer stop()
site, out := startRProxy(t, "http://"+addr, app.URL) site, out := startRProxy(t, "http://"+addr, app.URL)
@@ -409,13 +411,13 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
id, _ := line["request_id"].(string) id, _ := line["request_id"].(string)
report := serveWAFLine(t, serveWAFOut, id, "report") report := discriminatorLine(t, discriminatorOut, id, "report")
if report["status"] != 599.0 { 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 // settings in env, each as NAME=value. It returns where rproxy listens, the
// process, and what rproxy writes. // process, and what rproxy writes.
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) { 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 return addr, cmd, out
} }
func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Parallel() t.Parallel()
protocols := make(chan string, 10) protocols := make(chan string, 10)
backend := httptest.NewUnstartedServer(http.HandlerFunc( backend := httptest.NewUnstartedServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(servewaf.ClientHeader) != "" { if r.Header.Get(discriminator.ClientHeader) != "" {
protocols <- r.Proto protocols <- r.Proto
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionDeny) w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionDeny)
w.WriteHeader(http.StatusForbidden) 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 // 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. // 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{ err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw, 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") status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
if status != http.StatusForbidden { 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 { select {
@@ -503,7 +505,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol) t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
} }
case <-time.After(waitLimit): 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{}) release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(servewaf.ClientHeader) != "" { if r.Header.Get(discriminator.ClientHeader) != "" {
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow) w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
return return
} }
@@ -640,7 +642,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow) w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
})) }))
t.Cleanup(backend.Close) t.Cleanup(backend.Close)
@@ -703,7 +705,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
{"no backend", wafBackend, nil}, {"no backend", wafBackend, nil},
{ {
"another invalid", rproxyTimeout, "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) { t.Run(tc.name, func(t *testing.T) {
@@ -740,7 +742,7 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
out := &output{} out := &output{}
status := runRProxy(t.Context(), map[string]string{ status := runRProxy(t.Context(), map[string]string{
wafBackend: "http://servewaf:8080", wafBackend: "http://discriminator:8080",
rproxyListenAddr: taken.Addr().String(), rproxyListenAddr: taken.Addr().String(),
}, out) }, out)
if status != 1 { if status != 1 {
@@ -757,7 +759,7 @@ func TestMainRunsRProxy(t *testing.T) {
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9") addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+servewaf.HealthPath, http.NoBody) "http://"+addr+discriminator.HealthPath, http.NoBody)
if err != nil { if err != nil {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
@@ -1,11 +1,11 @@
// Package smallwebwaf runs the smallwebwaf process, as the command it is given. // Package sww runs the sww process, as the command it is given.
// `smallwebwaf servewaf` reads the settings, the rule files, the lookup database // `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 // 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 // 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. // container's health check.
package smallwebwaf package sww
import ( import (
"context" "context"
@@ -22,39 +22,39 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
) )
// shutdownTimeout is how long requests in progress may take to finish // 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. // runit and docker wait a little longer before they kill the process.
const shutdownTimeout = 5 * time.Second const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as servewaf stops, the log lines still // remoteLogStopTimeout is how long, as discriminator stops, the log lines still
// waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has // waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has
// carried them. // carried them.
const remoteLogStopTimeout = 2 * time.Second 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. // know.
const usageStatus = 2 const usageStatus = 2
// usage is what smallwebwaf prints then. // usage is what sww prints then.
const usage = `usage: smallwebwaf <command> const usage = `usage: sww <command>
commands: commands:
rproxy the reverse proxy in front of one app rproxy the reverse proxy in front of one app
servewaf decides for rproxy, and keeps bans, counters and history discriminator decides for rproxy, and keeps bans, counters and history
healthcheck the container's health check 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 { type Params struct {
// Version is the version of the binary, set when it is built. // Version is the version of the binary, set when it is built.
Version string Version string
@@ -65,7 +65,7 @@ type Params struct {
} }
// Main runs the command os.Args names, and returns the process's exit status. // 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 // With no command, or one it does not know, it prints the commands and returns
// usageStatus. // usageStatus.
func Main(version string) int { func Main(version string) int {
@@ -83,8 +83,8 @@ func Main(version string) int {
switch args[0] { switch args[0] {
case "rproxy": case "rproxy":
return RunRProxy(ctx, params) return RunRProxy(ctx, params)
case "servewaf": case "discriminator":
return RunServeWAF(ctx, params) return RunDiscriminator(ctx, params)
case "healthcheck": case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr) return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
default: default:
@@ -99,10 +99,10 @@ func printUsage(w io.Writer) int {
return usageStatus 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 // 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. // done. It returns the process's exit status, 1 when discriminator cannot start.
func RunServeWAF(ctx context.Context, params Params) int { func RunDiscriminator(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's // Until the settings are read, the one message is an invalid setting's
// error, which every SWWAF_LOG_LEVEL lets through. // error, which every SWWAF_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout, processLog := requestlog.NewProcessLogger(params.Stdout,
@@ -184,13 +184,13 @@ func RunServeWAF(ctx context.Context, params Params) int {
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog) return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
} }
// newServer returns the server servewaf runs, with the metrics of the alerts, // newServer returns the server discriminator runs, with the metrics of the alerts,
// after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup // after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error. // database that cannot be read is an error.
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
) (*servewaf.Server, error) { ) (*discriminator.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
if cfg.LookupSource == "file" { if cfg.LookupSource == "file" {
@@ -207,7 +207,7 @@ func newServer(
} }
} }
server := servewaf.New(servewaf.Params{ server := discriminator.New(discriminator.Params{
Config: cfg, Config: cfg,
RequestLog: stdout, RequestLog: stdout,
ProcessLog: processLog, ProcessLog: processLog,
@@ -230,7 +230,7 @@ func newServer(
// loadStateFiles reads the state files into the parts of server and into // loadStateFiles reads the state files into the parts of server and into
// alertQueue, as state.Load does. // alertQueue, as state.Load does.
func loadStateFiles( 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, now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) { ) (*state.Files, error) {
return state.Load(state.Params{ return state.Load(state.Params{
@@ -286,7 +286,7 @@ func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
// startSending runs remote until the function it returns is called, which // startSending runs remote until the function it returns is called, which
// then waits at most remoteLogStopTimeout for the lines still waiting to // 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 // 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( func startSending(
ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger, ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger,
) func() { ) func() {
@@ -316,7 +316,7 @@ func startSending(
// shutdownTimeout to finish, and writes every state file, alerts.json with // shutdownTimeout to finish, and writes every state file, alerts.json with
// the alerts still waiting. // the alerts still waiting.
func serve( 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, files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
processLog *slog.Logger, processLog *slog.Logger,
) int { ) int {
@@ -1,4 +1,4 @@
package smallwebwaf package sww
import ( import (
"log/slog" "log/slog"
@@ -1,4 +1,4 @@
package smallwebwaf_test package sww_test
import ( import (
"bytes" "bytes"
@@ -19,9 +19,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
const ( const (
@@ -29,7 +29,7 @@ const (
waitLimit = 10 * time.Second waitLimit = 10 * time.Second
// pollInterval is how often a test looks for a line. // pollInterval is how often a test looks for a line.
pollInterval = 10 * time.Millisecond pollInterval = 10 * time.Millisecond
// testVersion is the version the tests give smallwebwaf. // testVersion is the version the tests give sww.
testVersion = "test" testVersion = "test"
// localhost is where the tests listen. // localhost is where the tests listen.
localhost = "127.0.0.1" localhost = "127.0.0.1"
@@ -56,13 +56,13 @@ const (
placed = "203.0.113.9" placed = "203.0.113.9"
) )
// output collects what smallwebwaf writes on stdout. // output collects what sww writes on stdout.
type output struct { type output struct {
mu sync.Mutex mu sync.Mutex
buf bytes.Buffer buf bytes.Buffer
} }
// Write adds lines smallwebwaf writes. // Write adds lines sww writes.
func (o *output) Write(p []byte) (int, error) { func (o *output) Write(p []byte) (int, error) {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
@@ -122,11 +122,11 @@ func (o *output) text() string {
return o.buf.String() return o.buf.String()
} }
// run runs `smallwebwaf servewaf` with the settings in env until ctx is done, // run runs `sww discriminator` with the settings in env until ctx is done,
// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it, // and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses. // so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int { 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, Version: testVersion,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
value, ok := env[name] value, ok := env[name]
@@ -263,7 +263,7 @@ func TestServesUntilToldToStop(t *testing.T) {
func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) { func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
t.Parallel() 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. // serves, after its starting line.
lists := httptest.NewServer(http.HandlerFunc( lists := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
@@ -324,7 +324,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
var metrics string var metrics string
out := runUntilStopped(t, env, func(url 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. // The process's lines from its start to its stop, and the request's.
@@ -356,7 +356,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
rateLimitPerDay: "2", rateLimitPerDay: "2",
// Neither comes due in the test: the files are written as // Neither comes due in the test: the files are written as
// smallwebwaf stops. // sww stops.
stateWriteDelay: "1h", stateWriteDelay: "1h",
stateCounterInterval: "1h", stateCounterInterval: "1h",
} }
@@ -461,13 +461,13 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
adminToken: adminSecret, adminToken: adminSecret,
// Neither comes due in the test: the files are written as // Neither comes due in the test: the files are written as
// smallwebwaf stops. // sww stops.
stateWriteDelay: "1h", stateWriteDelay: "1h",
stateCounterInterval: "1h", stateCounterInterval: "1h",
} }
runUntilStopped(t, env, func(url string) { 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", `+ `{"netblock": "203.0.113.0/24", "duration": "permanent", `+
`"reason": "probes for logins"}`) `"reason": "probes for logins"}`)
wantStatus(t, url, "203.0.113.9", http.StatusForbidden) wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
@@ -484,7 +484,7 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
// more, and bans.json keeps it, marked lifted. // more, and bans.json keeps it, marked lifted.
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
wantStatus(t, url, "203.0.113.9", http.StatusForbidden) 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) wantStatus(t, url, "203.0.113.9", http.StatusOK)
}) })
@@ -511,7 +511,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
wantAllowed(t, url) wantAllowed(t, url)
// Written once: each change would start the rule files' wait // 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. // read once it does.
err := os.WriteFile(filepath.Join(dir, "50-app.rules"), err := os.WriteFile(filepath.Join(dir, "50-app.rules"),
[]byte("everything path block ^/\n"), 0o600) []byte("everything path block ^/\n"), 0o600)
@@ -583,7 +583,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
wantStatus(t, url, placed, http.StatusOK) 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 // which may be after the first replacement. Once that has taken
// effect, only the watch can show the next. Each takes as long as // 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. // it takes, so that a slow test process cannot fail the test.
@@ -609,10 +609,10 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
rename(t, replacement, path) rename(t, replacement, path)
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, metrics := metricsWith(t, url+"_sww/metrics", token,
`smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`) `sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`)
lastRead = seriesValue(t, metrics, 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) wantStatus(t, url, placed, http.StatusOK)
}) })
@@ -668,13 +668,13 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
// too. // too.
rateLimitExemptNets: placed + "," + localhost, rateLimitExemptNets: placed + "," + localhost,
} }
failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/blog",` + failures := `sww_reputation_failures_total{instance="fsn1app1/blog",` +
`source="` + torURL + `"} ` `source="` + torURL + `"} `
// tor.txt cannot be fetched at first, which is counted. // tor.txt cannot be fetched at first, which is counted.
failing.Store(true) failing.Store(true)
runUntilStopped(t, env, func(url string) { 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 // Restarted with drop.txt named after it and the server answering, tor.txt
@@ -849,7 +849,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
}) })
out.line(t, "type", "request") 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. // it has sent the lines written as it stopped.
conn, err := endpoint.Accept() conn, err := endpoint.Accept()
if err != nil { if err != nil {
@@ -907,17 +907,17 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
// More than one line has been written, and the buffer holds the // More than one line has been written, and the buffer holds the
// last. // last.
metrics := metricsText(t, url+"_smallwebwaf/metrics", token) metrics := metricsText(t, url+"_sww/metrics", token)
for _, series := range []string{ for _, series := range []string{
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`, `sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`, `sww_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
} { } {
if !strings.Contains(metrics, "\n"+series+"\n") { if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics) 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"} ` `{instance="fsn1app1/blog"} `
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) { if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
t.Errorf("no line dropped in the metrics:\n%s", metrics) t.Errorf("no line dropped in the metrics:\n%s", metrics)
@@ -978,8 +978,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
webhook.waitFor(t, "permanent_ban", false) webhook.waitFor(t, "permanent_ban", false)
}) })
// alerts.json keeps it as smallwebwaf stops, and once started again, // alerts.json keeps it as sww stops, and once started again,
// smallwebwaf sends it. // sww sends it.
var file struct { var file struct {
Waiting map[string][]struct { Waiting map[string][]struct {
Event string `json:"event"` Event string `json:"event"`
@@ -1013,11 +1013,11 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}` const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}`
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, metrics := metricsWith(t, url+"_sww/metrics", token,
"\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n") "\nsww_alerts_sent_total"+ofWebhook+" 1\n")
for _, series := range []string{"failed", "suppressed", "dropped"} { 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) { if !strings.Contains(metrics, zero) {
t.Errorf("no %q in the metrics:\n%s", zero, metrics) t.Errorf("no %q in the metrics:\n%s", zero, metrics)
} }
@@ -1079,13 +1079,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
// webhook, which is not set. As long as that takes, so that a slow // webhook, which is not set. As long as that takes, so that a slow
// test process cannot fail the test. // test process cannot fail the test.
sent := []string{ sent := []string{
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," + "\nsww_alerts_sent_total{destination=\"slack\"," +
"instance=\"fsn1app1/blog\"} 1\n", "instance=\"fsn1app1/blog\"} 1\n",
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," + "\nsww_alerts_sent_total{destination=\"ntfy\"," +
"instance=\"fsn1app1/blog\"} 1\n", "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"`) { if strings.Contains(metrics, `destination="webhook"`) {
t.Errorf("the metrics give the webhook:\n%s", metrics) t.Errorf("the metrics give the webhook:\n%s", metrics)
} }
@@ -1114,7 +1114,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
"SWWAF_STATE_DIR cannot be written: ") "SWWAF_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 // checks that it stops at start, with an error that starts with want. If
// it starts instead, it is stopped after waitLimit. // it starts instead, it is stopped after waitLimit.
func wantStartRefused(t *testing.T, dir, want string) { func wantStartRefused(t *testing.T, dir, want string) {
@@ -1140,7 +1140,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 // checks that it stops at start, with the error want. If it starts
// instead, it is stopped after waitLimit. // instead, it is stopped after waitLimit.
func wantRulesRefused(t *testing.T, dir, want string) { func wantRulesRefused(t *testing.T, dir, want string) {
@@ -1178,7 +1178,7 @@ func startApp(t *testing.T) string {
return app.URL 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 // send it requests at url, then stops it as SIGTERM does, checks that it
// stopped in order, and returns its output. // stopped in order, and returns its output.
func runUntilStopped( func runUntilStopped(
@@ -1252,7 +1252,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) { func wantAllowed(t *testing.T, url string) {
t.Helper() t.Helper()
@@ -1437,7 +1437,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
return file.Bans[0] return file.Bans[0]
} }
// wantRefused checks that servewaf at url refuses a request from localhost with // wantRefused checks that discriminator at url refuses a request from localhost with
// 403, the default SWWAF_BAN_RESPONSE. // 403, the default SWWAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) { func wantRefused(t *testing.T, url string) {
t.Helper() t.Helper()
@@ -1459,7 +1459,7 @@ func wantStatus(t *testing.T, url, from string, status int) {
// saveUntilAnswered writes content to the state file at path, as an // 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 // 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 // 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 // first written. It waits as long as that takes, so that a slow test
// process cannot fail the test. // process cannot fail the test.
func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) { func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) {
@@ -1614,8 +1614,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 // 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 // answered with. The request serves rproxy and discriminator alike. rproxy reads the
// client from X-Forwarded-For, and replaces the other two headers. To servewaf, // client from X-Forwarded-For, and replaces the other two headers. To discriminator,
// they make it a question about that client. // they make it a question about that client.
func statusFrom(t *testing.T, url, from string) int { func statusFrom(t *testing.T, url, from string) int {
t.Helper() t.Helper()
@@ -1627,7 +1627,7 @@ func statusFrom(t *testing.T, url, from string) int {
} }
req.Header.Set("X-Forwarded-For", from) 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()) req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
+5 -5
View File
@@ -1,10 +1,10 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // 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 // 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 six changes smallwebwaf // body while SWWAF_WAF_BODY_LIMIT is set, with the six changes sww
// makes to it, as "Attack detection" under "Settings" in SPEC.md describes // makes to it, as "Attack detection" under "Settings" in SPEC.md describes
// them. It reads no response. // 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 // 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 // file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory. // writing them to the system's temporary directory.
@@ -25,15 +25,15 @@ import (
"github.com/corazawaf/coraza/v3/types" "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, and bodyDirectives for %s while // paranoia level for %d, and bodyDirectives for %s while
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from // SWWAF_WAF_BODY_LIMIT is set. Each rule sww adds has an id from
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set // 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting // it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
// switches one off. Coraza joins a line ending in \ to the next, without // switches one off. Coraza joins a line ending in \ to the next, without
// the spaces at the start of the next. // the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. smallwebwaf compares the request's anomaly # The engine only detects. sww compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
+2 -2
View File
@@ -111,7 +111,7 @@ func matched(ids ...int) waf.Result {
return waf.Result{RuleIDs: ids, Score: critical * len(ids)} 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 { func atDefaults(t *testing.T) *waf.CoreRuleSet {
t.Helper() t.Helper()
@@ -376,7 +376,7 @@ const (
// injection is an SQL injection, which rule 942100 matches. // injection is an SQL injection, which rule 942100 matches.
const injection = "1' OR '1'='1" 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. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *waf.CoreRuleSet { func readingBodies(t *testing.T) *waf.CoreRuleSet {
t.Helper() t.Helper()
+2 -2
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/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 # 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. # in script/docker, and the no_fs_access tag is the Dockerfile's.
set -eu set -eu
@@ -12,7 +12,7 @@ main() {
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \ go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \
-o bin/smallwebwaf ./cmd/smallwebwaf -o bin/sww ./cmd/sww
} }
main "$@" main "$@"
+70 -70
View File
@@ -1,25 +1,25 @@
#!/bin/sh #!/bin/sh
# script/example-app: build the image, and on it the example app in # 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 # 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: # checks:
# - that the health checks pass; # - that the health checks pass;
# - that a request is served through rproxy, and a second one in a minute # - that a request is served through rproxy, and a second one in a minute
# bans the client; # bans the client;
# - that a probe for /.env bans another client, which its next request bans # - that a probe for /.env bans another client, which its next request bans
# for good; # for good;
# - that only servewaf's container changes the state directory's owner; # - that only discriminator's container changes the state directory's owner;
# - that the app still answers through rproxy with servewaf stopped, and # - that the app still answers through rproxy with discriminator stopped, and
# that once servewaf has started again, bans apply again; # that once discriminator has started again, bans apply again;
# - that `sv stop` stops rproxy in order, and `docker stop` stops each # - that `sv stop` stops rproxy in order, and `docker stop` stops each
# container without having to kill it; # 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; # client;
# - that a servewaf container with its state files on a new volume mounted at # - that a discriminator container with its state files on a new volume mounted at
# the directory SWWAF_STATE_DIR_FILE names becomes healthy; # the directory SWWAF_STATE_DIR_FILE names becomes healthy;
# - that one whose SWWAF_STATE_DIR does not exist logs servewaf's message # - that one whose SWWAF_STATE_DIR does not exist logs discriminator's message
# about it. # about it.
# servewaf runs with SWWAF_LOOKUP_SOURCE=off, so that no address is sent to # discriminator runs with SWWAF_LOOKUP_SOURCE=off, so that no address is sent to
# GeoJS. The containers, the network, the volumes and both images are removed # GeoJS. The containers, the network, the volumes and both images are removed
# however the script ends. Building the app needs network access, for nixpkgs' # however the script ends. Building the app needs network access, for nixpkgs'
# binary cache. script/check does not run this. # binary cache. script/check does not run this.
@@ -35,7 +35,7 @@ IMAGE="$NAME-base"
APP_IMAGE="$NAME-app" APP_IMAGE="$NAME-app"
NETWORK="$NAME" NETWORK="$NAME"
APP="$NAME-app-container" APP="$NAME-app-container"
SERVEWAF="$NAME-servewaf" DISCRIMINATOR="$NAME-discriminator"
VOLUME="$NAME-state" VOLUME="$NAME-state"
# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE # The volumes of the container whose state directory SWWAF_STATE_DIR_FILE
# names: its state files, and the file that names their directory. # names: its state files, and the file that names their directory.
@@ -43,7 +43,7 @@ MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings" SETTINGS_VOLUME="$NAME-settings"
cleanup() { 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 network rm "$NETWORK" >/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \ docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
@@ -53,8 +53,8 @@ cleanup() {
fail() { fail() {
echo "example-app: $*; the app container's output:" >&2 echo "example-app: $*; the app container's output:" >&2
docker logs "$APP" >&2 || true docker logs "$APP" >&2 || true
echo "example-app: servewaf's output:" >&2 echo "example-app: discriminator's output:" >&2
docker logs "$SERVEWAF" >&2 || true docker logs "$DISCRIMINATOR" >&2 || true
exit 1 exit 1
} }
@@ -87,49 +87,49 @@ logged() {
done 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 # volume, a rate limit of one request a minute and no client looked up, and wait
# until it is healthy. # until it is healthy.
start_servewaf() { start_discriminator() {
docker run --detach --name "$SERVEWAF" --network "$NETWORK" \ docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
--volume "$VOLUME:/var/lib/smallwebwaf" \ --volume "$VOLUME:/var/lib/sww" \
--env SMALLWEBWAF_COMMAND=servewaf \ --env SWW_COMMAND=discriminator \
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \ --env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$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 # start_app: run the app's container, with rproxy in it, and wait until it is
# healthy. # healthy.
start_app() { start_app() {
docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \ 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 "$APP_IMAGE" >/dev/null
wait_for "the app's health check did not pass" healthy "$APP" wait_for "the app's health check did not pass" healthy "$APP"
address="$(docker port "$APP" 8080/tcp)" address="$(docker port "$APP" 8080/tcp)"
} }
# start_moved_servewaf: run servewaf with the state files on a new volume at # start_moved_discriminator: run discriminator with the state files on a new volume at
# /srv/state, which SWWAF_STATE_DIR_FILE names in a file only the smallwebwaf # /srv/state, which SWWAF_STATE_DIR_FILE names in a file only the sww
# user can read, as under "Settings given as files" in docs/settings.md, and # 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. # script has given it to that user.
start_moved_servewaf() { start_moved_discriminator() {
docker volume create "$MOVED_VOLUME" >/dev/null docker volume create "$MOVED_VOLUME" >/dev/null
docker volume create "$SETTINGS_VOLUME" >/dev/null docker volume create "$SETTINGS_VOLUME" >/dev/null
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \ docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir && --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' 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 "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \ --volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
--env SMALLWEBWAF_COMMAND=servewaf \ --env SWW_COMMAND=discriminator \
--env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \ --env SWWAF_STATE_DIR_FILE=/etc/sww/settings/state-dir \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$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 <client> <path>: print the status a request for path from client, # status_from <client> <path>: print the status a request for path from client,
@@ -153,9 +153,9 @@ refused_from() {
[ "$(status_from "$1" "$2")" = 403 ] [ "$(status_from "$1" "$2")" = 403 ]
} }
# owner_of_state_dir <container>: print who owns /var/lib/smallwebwaf there. # owner_of_state_dir <container>: print who owns /var/lib/sww there.
owner_of_state_dir() { owner_of_state_dir() {
docker exec "$1" stat --format '%U' /var/lib/smallwebwaf docker exec "$1" stat --format '%U' /var/lib/sww
} }
main() { main() {
@@ -169,86 +169,86 @@ main() {
docker network create "$NETWORK" >/dev/null docker network create "$NETWORK" >/dev/null
docker volume create "$VOLUME" >/dev/null docker volume create "$VOLUME" >/dev/null
start_servewaf start_discriminator
start_app start_app
echo "example-app: the health checks pass" echo "example-app: the health checks pass"
[ "$(owner_of_state_dir "$SERVEWAF")" = smallwebwaf ] || [ "$(owner_of_state_dir "$DISCRIMINATOR")" = sww ] ||
fail "servewaf's container did not give the state directory to smallwebwaf" fail "discriminator's container did not give the state directory to sww"
[ "$(owner_of_state_dir "$APP")" = root ] || [ "$(owner_of_state_dir "$APP")" = root ] ||
fail "the app's container changed the state directory's owner" 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/")" || page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
fail "no answer on port 8080" fail "no answer on port 8080"
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page" [ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
wait_for "servewaf logged no request it allowed" \ wait_for "discriminator logged no request it allowed" \
logged "$SERVEWAF" '"action":"forward"' '"servewaf_line":"report"' logged "$DISCRIMINATOR" '"action":"forward"' '"discriminator_line":"report"'
echo "example-app: rproxy passes a request to the app and its answer back" 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" 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" 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" refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
wait_for "servewaf logged no ban for the probe" \ wait_for "discriminator logged no ban for the probe" \
logged "$SERVEWAF" '"action":"banned"' '"rule_ids":["env-file"]' logged "$DISCRIMINATOR" '"action":"banned"' '"rule_ids":["env-file"]'
refused_from 203.0.113.9 / || fail "the client of the probe was let through" 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" \ 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" echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$SERVEWAF" sv stop smallwebwaf >/dev/null || docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
fail "sv stop smallwebwaf failed in servewaf's container" fail "sv stop sww failed in discriminator's container"
wait_for "servewaf did not stop in order" logged "$SERVEWAF" '"msg":"stopped"' wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"'
[ "$(status_from 203.0.113.9 /)" = 200 ] || [ "$(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" \ wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged "$APP" '"default_disposition":true' logged "$APP" '"default_disposition":true'
docker exec "$APP" /usr/local/bin/smallwebwaf healthcheck || docker exec "$APP" /usr/local/bin/sww healthcheck ||
fail "with servewaf stopped, the app's health check failed" fail "with discriminator stopped, the app's health check failed"
echo "example-app: with servewaf stopped, the app answers through rproxy" echo "example-app: with discriminator stopped, the app answers through rproxy"
docker exec "$SERVEWAF" sv start smallwebwaf >/dev/null || docker exec "$DISCRIMINATOR" sv start sww >/dev/null ||
fail "sv start smallwebwaf failed in servewaf's container" fail "sv start sww failed in discriminator's container"
wait_for "once servewaf had started again, the ban did not apply" \ wait_for "once discriminator had started again, the ban did not apply" \
refused_from 203.0.113.9 / 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 || docker exec "$APP" sv stop sww >/dev/null ||
fail "sv stop smallwebwaf failed in the app's container" fail "sv stop sww failed in the app's container"
wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"' wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"'
echo "example-app: sv stop stops rproxy in order" 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 docker stop "$container" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$container")" status="$(docker inspect --format '{{.State.ExitCode}}' "$container")"
[ "$status" = 0 ] || fail "docker stop left exit status $status" [ "$status" = 0 ] || fail "docker stop left exit status $status"
done done
echo "example-app: docker stop stops each container in order" echo "example-app: docker stop stops each container in order"
docker rm "$APP" "$SERVEWAF" >/dev/null docker rm "$APP" "$DISCRIMINATOR" >/dev/null
start_servewaf start_discriminator
start_app start_app
refused || fail "the new servewaf container let the banned client through" refused || fail "the new discriminator container let the banned client through"
wait_for "servewaf logged no request refused under the ban" \ wait_for "discriminator logged no request refused under the ban" \
logged "$SERVEWAF" '"action":"banned"' logged "$DISCRIMINATOR" '"action":"banned"'
echo "example-app: a new servewaf container on the same volume keeps the ban" echo "example-app: a new discriminator container on the same volume keeps the ban"
docker rm --force "$SERVEWAF" >/dev/null docker rm --force "$DISCRIMINATOR" >/dev/null
start_moved_servewaf start_moved_discriminator
echo "example-app: the run script gives the state files' directory named" \ echo "example-app: the run script gives the state files' directory named" \
"by SWWAF_STATE_DIR_FILE to the smallwebwaf user" "by SWWAF_STATE_DIR_FILE to the sww user"
docker rm --force "$SERVEWAF" >/dev/null docker rm --force "$DISCRIMINATOR" >/dev/null
docker run --detach --name "$SERVEWAF" --network "$NETWORK" \ docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
--env SMALLWEBWAF_COMMAND=servewaf \ --env SWW_COMMAND=discriminator \
--env SWWAF_STATE_DIR=/srv/missing \ --env SWWAF_STATE_DIR=/srv/missing \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "servewaf did not report the missing state directory" \ wait_for "discriminator did not report the missing state directory" \
logged "$SERVEWAF" "SWWAF_STATE_DIR cannot be written" logged "$DISCRIMINATOR" "SWWAF_STATE_DIR cannot be written"
echo "example-app: servewaf reports a state directory that does not exist" echo "example-app: discriminator reports a state directory that does not exist"
} }
main "$@" main "$@"
+8 -8
View File
@@ -1,9 +1,9 @@
#!/bin/sh #!/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 # 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 # 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. # 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 SWWAF_LISTEN_ADDR says otherwise, and
# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless # 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, # 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, # and unless SWWAF_RULES_DIR is set, the rule files are those of share/rules.d,
@@ -13,14 +13,14 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
SERVEWAF="" DISCRIMINATOR=""
RPROXY="" 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. # files.
stop_both() { stop_both() {
trap '' HUP INT TERM trap '' HUP INT TERM
kill $SERVEWAF $RPROXY 2>/dev/null || true kill $DISCRIMINATOR $RPROXY 2>/dev/null || true
wait || true wait || true
} }
@@ -42,9 +42,9 @@ main() {
trap stop_both EXIT trap stop_both EXIT
trap 'exit 1' HUP INT TERM trap 'exit 1' HUP INT TERM
"$ROOT/bin/smallwebwaf" servewaf & "$ROOT/bin/sww" discriminator &
SERVEWAF=$! DISCRIMINATOR=$!
"$ROOT/bin/smallwebwaf" rproxy & "$ROOT/bin/sww" rproxy &
RPROXY=$! RPROXY=$!
wait "$RPROXY" wait "$RPROXY"
} }
+16 -16
View File
@@ -1,47 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# runit's run script for smallwebwaf, run again whenever smallwebwaf # runit's run script for sww, run again whenever sww
# exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the # exits; the wait spaces out the restarts. SWW_COMMAND names the
# command to run: rproxy when it is unset or empty, or servewaf. With any # 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. # other value the script starts nothing and stops with a message naming it.
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself. # exec, so that the signal `sv stop` sends reaches sww itself.
main() { main() {
sleep 1 sleep 1
local command="${SMALLWEBWAF_COMMAND:-rproxy}" local command="${SWW_COMMAND:-rproxy}"
case "$command" in case "$command" in
rproxy) ;; rproxy) ;;
servewaf) give_state_dir_to_smallwebwaf ;; discriminator) give_state_dir_to_sww ;;
*) *)
printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \ printf 'SWW_COMMAND: "%s" is not rproxy or discriminator\n' \
"$command" >&2 "$command" >&2
exit 1 exit 1
;; ;;
esac esac
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command" exec chpst -u sww:sww /usr/local/bin/sww "$command"
} }
# give_state_dir_to_smallwebwaf gives the state directory and every file in # give_state_dir_to_sww gives the state directory and every file in
# it to the smallwebwaf user, so that a volume mounted there needs no change # 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 # 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 # 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 # 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 # 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 # on a volume mounted read-only, does not end the script either: discriminator
# reports a directory it cannot write. # reports a directory it cannot write.
give_state_dir_to_smallwebwaf() { give_state_dir_to_sww() {
local dir local dir
if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then
chown -R smallwebwaf:smallwebwaf "$dir" || true chown -R sww:sww "$dir" || true
fi fi
} }
# state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset, # state_dir prints SWWAF_STATE_DIR, /var/lib/sww when it is unset,
# or what is in the file SWWAF_STATE_DIR_FILE names, less every newline at # 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. # its end. It fails when both are set or the file cannot be read.
state_dir() { state_dir() {
if [[ ! -v SWWAF_STATE_DIR_FILE ]]; then if [[ ! -v SWWAF_STATE_DIR_FILE ]]; then
printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/smallwebwaf}" printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/sww}"
elif [[ ! -v SWWAF_STATE_DIR ]]; then elif [[ ! -v SWWAF_STATE_DIR ]]; then
cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null
else else