Compare commits

2 Commits
Author SHA1 Message Date
sneak 174b6edca7 waf: the header limits are the first check (closes #311)
check / check (push) Successful in 3m19s
A request over 50 cookies or 100 header fields is now refused by the first
check after SWW_ALLOW_NETS. It causes no lookup, no query to RouteWatch, a DNS
blocklist or AbuseIPDB, no rule file match and no Core Rule Set. The rate
limits no longer count it, and a banned or denied client gets 431 for it. It
makes no ban, makes no ban permanent and is not counted in a ban's notes. In
observe mode no lookup is made for it. Requests within the limits go through
the same checks in the same order.

Model: opus-5-5
2026-11-11 02:14:23 +00:00
clawbot f7e3190654 Rename discriminator to waf everywhere (closes #312)
check / check (push) Successful in 5m58s
The command is now `sww waf`, and the package is `internal/waf`. Types,
functions, files, tests, comments, docs, the Dockerfile, the runit
script, `script/`, compose examples, the log field `waf_line` and the
`X-From-Waf` test header follow. No behaviour changes, and no setting
changes name: rproxy already finds the waf through `SWRP_WAF_BACKEND`.

Done with one `sed -i` (Discriminator and DISCRIMINATOR to WAF,
discriminator to waf) over the files `git grep -i discriminator`
listed, as sneak allowed for this rename, then `git mv` for the
package and files. By hand afterwards: the header's spelling, which
the linter requires, and the padding after `waf` in `sww`'s usage
text.

Model: opus-5-5
2026-11-11 02:43:16 +01:00
89 changed files with 1457 additions and 1495 deletions

No files matched your search

+2 -2
View File
@@ -155,7 +155,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
-o /usr/local/bin/runsvinit .
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
# SWW_COMMAND says waf, and an app's Dockerfile can build FROM it.
# It is the last stage, so a plain `docker build .` builds it.
#
# ubuntu 26.04, 2026-09-27
@@ -223,7 +223,7 @@ RUN groupadd --system --gid 65532 sww \
# The state files' directory, SWW_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the
# sww user at each start of discriminator.
# sww user at each start of waf.
RUN mkdir /var/lib/sww
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
+106 -109
View File
@@ -11,16 +11,15 @@ refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
shipped in one image, with two commands that do this together:
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
it. It asks `discriminator` about each request, passes the allowed ones to the
app, and passes the app's answers back. It keeps nothing and never touches
disk.
- `sww discriminator` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `discriminator` serves many sites: a
client banned through one site is refused on all of them, and its requests on
all of them count toward one set of limits.
it. It asks `waf` about each request, passes the allowed ones to the app, and
passes the app's answers back. It keeps nothing and never touches disk.
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
each client's history. One `waf` serves many sites: a client banned through
one site is refused on all of them, and its requests on all of them count
toward one set of limits.
The site keeps answering while `discriminator` is down, slow or restarting:
without an answer in time, `rproxy` passes the request to the app.
The site keeps answering while `waf` is down, slow or restarting: without an
answer in time, `rproxy` passes the request to the app.
It protects against:
@@ -80,11 +79,11 @@ make docker
Push the image to a registry your hosts pull from, and name it there by digest.
The image runs `rproxy` by default. To run `discriminator` from the same image,
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
the app accepts connections, whether or not `discriminator` answers; in
`discriminator`'s container, once `discriminator` answers.
The image runs `rproxy` by default. To run `waf` from the same image, set the
container's `SWW_COMMAND` to `waf`. The image's health check follows the
command: in `rproxy`'s container it passes once `rproxy` answers and the app
accepts connections, whether or not `waf` answers; in `waf`'s container, once
`waf` answers.
### The usual setup: three containers
@@ -92,24 +91,24 @@ The usual setup is three containers on one private network:
- the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
`/var/lib/sww` for its state files.
names `waf` and `SWRP_UPSTREAM_URL` names the app;
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
files.
Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `discriminator`. Anything on that network can reach the app
around `rproxy`, and can ask `discriminator` questions, so the network holds
only what you trust. Each further app adds its own container and its own
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
setup" example below shows this shape.
of the app or of `waf`. Anything on that network can reach the app around
`rproxy`, and can ask `waf` questions, so the network holds only what you trust.
Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `waf`. The "Smallest setup" example below
shows this shape.
### `rproxy` in the app's container
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app in one container. `discriminator` still runs in a container
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
by digest, and adds:
runs beside the app in one container. `waf` still runs in a container of its
own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
digest, and adds:
- the app's binary;
- the packages the app needs, from the nixpkgs in the image;
@@ -169,26 +168,25 @@ does.
- A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer.
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `discriminator` with
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes.
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
request have `"action":"forward"`.
- To see a refusal without banning yourself, run `waf` with `SWW_MODE=observe`
and ask for `/.env`. The request reaches the app. `waf`'s log lines have
`"would_action":"temporarily_banned"` and `"rule_ids":["env-file"]`. A ban you
get in `enforce` mode is lifted as "Clients and bans" below describes.
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
`discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
`waf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints").
## Configuration
Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one.
restarting the container. `rproxy` reads only the `SWRP_` settings, and `waf`
only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen
for a service on the open internet. A setting that needs an account, a key, a
token or an alert destination stays off until you give one.
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
@@ -222,23 +220,23 @@ Every setting the code reads, with its default:
| Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWRP_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
@@ -296,9 +294,9 @@ Every setting the code reads, with its default:
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
@@ -351,8 +349,8 @@ own, where each reaches the others by service name. Each example publishes
### Smallest setup
One app, `rproxy` in front of it, and `discriminator` with a volume for its
state files, every setting at its default.
One app, `rproxy` in front of it, and `waf` with a volume for its state files,
every setting at its default.
```yaml
services:
@@ -363,20 +361,20 @@ services:
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
discriminator:
waf:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
SWW_COMMAND: waf
volumes:
- discriminator-state:/var/lib/sww
- waf-state:/var/lib/sww
volumes:
discriminator-state:
waf-state:
```
### Custom per-address limits
@@ -399,15 +397,15 @@ services:
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
discriminator:
waf:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
SWW_COMMAND: waf
# Monitoring skips every check.
SWW_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
@@ -426,18 +424,18 @@ services:
SWW_BAN_RESPONSE: "429"
SWW_LIMIT_BAN_DURATION: 30m
volumes:
- discriminator-state:/var/lib/sww
- waf-state:/var/lib/sww
volumes:
discriminator-state:
waf-state:
```
### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `discriminator` is
down, and `discriminator`:
container". `rproxy` answers `503` to every request while `waf` is down, and
`waf`:
- observes before it enforces;
- looks addresses up in a local database rather than GeoJS;
@@ -450,17 +448,17 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://discriminator:8080
# Answer 503 rather than pass a request on while discriminator is down.
SWRP_WAF_BACKEND: http://waf:8080
# Answer 503 rather than pass a request on while waf is down.
SWRP_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
discriminator:
waf:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
SWW_INSTANCE_NAME: fsn1/discriminator
SWW_COMMAND: waf
SWW_INSTANCE_NAME: fsn1/waf
# Refuse nothing yet; log what enforce mode would have done.
SWW_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
@@ -471,12 +469,12 @@ services:
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
volumes:
- discriminator-state:/var/lib/sww
- waf-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/sww/tokens:/etc/sww/tokens:ro
volumes:
discriminator-state:
waf-state:
```
The database is mounted by its directory, not by file, so that a replaced
@@ -498,18 +496,18 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request
A request goes through these steps, in this order. Once `discriminator` refuses
a request, the steps after that are skipped, apart from the report, and nothing
of the request reaches the app.
A request goes through these steps, in this order. Once `waf` refuses a request,
the steps after that are skipped, apart from the report, and nothing of the
request reaches the app.
`rproxy` takes the first two:
1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
the visitor sent it, with the first 64 KiB of its body.
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the visitor
sent it, with the first 64 KiB of its body.
`discriminator` takes the checks:
`waf` takes the checks:
3. The header limits, then the tag lists. A client in `SWW_ALLOW_NETS` skips the
header limits.
@@ -529,13 +527,13 @@ of the request reaches the app.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix.
`rproxy` takes the rest, with `discriminator` counting at the end:
`rproxy` takes the rest, with `waf` counting at the end:
17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status
and the body bytes each way. For an allowed request, `discriminator` counts
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
they take it past one, and adds the request to the client's history.
18. After the answer, `rproxy` sends `waf` a report with the status and the body
bytes each way. For an allowed request, `waf` counts the bytes
`SWW_BYTES_COUNT` names toward the byte limits, bans the client if they take
it past one, and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
@@ -561,8 +559,8 @@ status for a size or time limit, such as `413` for a body too large.
### Request tags
Each request gets tags from what `discriminator` knows of its client, written in
the `tags` field of its log line:
Each request gets tags from what `waf` knows of its client, written in the
`tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
@@ -613,17 +611,17 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
while `discriminator` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)).
while `waf` runs. The image ships one file of probes no real visitor sends
(see [`docs/rules.md`](docs/rules.md)).
### State files
`discriminator` keeps everything in memory, and the five JSON files in
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
renamed over the real one, so a crash leaves a complete file. `discriminator`
notices a file edited on disk and takes the edit in; an edit that does not parse
is set aside as `<name>.bad`, and never stops the running process (see
`waf` keeps everything in memory, and the five JSON files in `SWW_STATE_DIR`
hold a copy: `bans.json`, `clients.json`, `lookups.json`, `reputation.json` and
`alerts.json`. Each is written whole, to a temporary file renamed over the real
one, so a crash leaves a complete file. `waf` notices a file edited on disk and
takes the edit in; an edit that does not parse is set aside as `<name>.bad`, and
never stops the running process (see
[`docs/state-files.md`](docs/state-files.md)).
### The image and its processes
@@ -633,20 +631,19 @@ is set aside as `<name>.bad`, and never stops the running process (see
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)).
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
and starts it again when it exits. The `run` script of `discriminator` gives
the state directory to the `sww` user at each start, so a volume needs no
change of owner.
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
endpoints, under `/_sww/`, are for your admin tools and your metrics
collector, at its own address on the private network; nothing routes the
internet there.
and starts it again when it exits. The `run` script of `waf` gives the state
directory to the `sww` user at each start, so a volume needs no change of
owner.
- `rproxy` and `waf` use no token between them. `waf`'s own endpoints, under
`/_sww/`, are for your admin tools and your metrics collector, at its own
address on the private network; nothing routes the internet there.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them.
- Only faults found at start stop a process: an invalid setting, a rule file or
state file that does not parse, a lookup database that cannot be read, or an
address it cannot listen on. Once running, a failing lookup service,
reputation source or alert destination never takes `discriminator` down.
reputation source or alert destination never takes `waf` down.
### Packages
@@ -654,8 +651,8 @@ is set aside as `<name>.bad`, and never stops the running process (see
- `internal/sww`: the process, as the command it is given, and the health check.
- `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app.
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
answers its own endpoints.
- `internal/waf`: `waf`'s server, which runs the checks and answers its own
endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
@@ -735,25 +732,25 @@ on the host.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
- `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
host, with the settings in your environment. `make run`. Unless your settings
say otherwise:
- `script/run`: builds `bin/sww` and runs `waf` and `rproxy` on the host, with
the settings in your environment. `make run`. Unless your settings say
otherwise:
- `rproxy` listens on port 8080, in front of an app at
`http://127.0.0.1:8081`;
- `discriminator` listens on port 8090;
- `waf` listens on port 8090;
- its state files go in `bin/state`;
- its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
`discriminator` in another, and checks them. Then it removes the containers,
the network, the volumes and the images. Needs network access.
`make example-app`. It checks:
`waf` in another, and checks them. Then it removes the containers, the
network, the volumes and the images. Needs network access. `make example-app`.
It checks:
- the health checks;
- a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent;
- that only `discriminator`'s container changes the state directory's owner;
- with `discriminator` stopped, the app still answering through `rproxy`;
- with `discriminator` started again, bans applying again;
- that only `waf`'s container changes the state directory's owner;
- with `waf` stopped, the app still answering through `rproxy`;
- with `waf` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume;
- a setting given as a file.
+302 -325
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,6 +1,6 @@
// 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
// proxy in front of one app, which asks waf about each request.
// `sww waf` 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
+17 -17
View File
@@ -1,8 +1,8 @@
# Admin endpoints
The endpoints under `/_sww/`, what each takes and what each answers.
`sww discriminator` answers them at its own address, on the private network.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
The endpoints under `/_sww/`, what each takes and what each answers. `sww waf`
answers them at its own address, on the private network. "Admin endpoints" in
[`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
@@ -20,10 +20,10 @@ The endpoints under `/_sww/`, what each takes and what each answers.
- one that names an IPv4-mapped netblock, or an address with a zone.
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
`<client>`. It answers `404` when none is active.
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
`client`, its counters and history as `clients.json` holds them, or `null`
when `clients.json` does not hold it. Under `bans`, every ban covering the
address, with its notes.
- `GET /_sww/clients/<ip>`: what `waf` knows of the client. Under `client`, its
counters and history as `clients.json` holds them, or `null` when
`clients.json` does not hold it. Under `bans`, every ban covering the address,
with its notes.
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
`204`, or `400` to a body that is no report (see "The report" in
[`SPEC.md`](../SPEC.md)).
@@ -32,29 +32,29 @@ 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.
`sww rproxy` answers only the health check of these, at its own address. It asks
`discriminator` about any other request under `/_sww/`. One that passes the
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
in [`SPEC.md`](../SPEC.md)).
`waf` about any other request under `/_sww/`. One that passes the checks is
answered `404`, so that the app never sees that prefix (see "`rproxy`" in
[`SPEC.md`](../SPEC.md)).
## Examples
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
as `discriminator:8080` on its network:
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
`waf:8080` on its network:
```sh
# Every ban.
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/bans
# Ban 203.0.113.0/24 for seven days.
curl -H "Authorization: Bearer $TOKEN" \
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
http://discriminator:8080/_sww/bans
http://waf:8080/_sww/bans
# Lift the bans on 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
http://discriminator:8080/_sww/bans/203.0.113.9
http://waf:8080/_sww/bans/203.0.113.9
# What discriminator knows of 203.0.113.9.
# What waf knows of 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" \
http://discriminator:8080/_sww/clients/203.0.113.9
http://waf:8080/_sww/clients/203.0.113.9
```
+6 -7
View File
@@ -6,7 +6,7 @@ back or dropped.
## Events
- `ban`: a ban `discriminator` made.
- `ban`: a ban `waf` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request.
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
@@ -34,7 +34,7 @@ on one line:
```json
{
"instance": "fsn1/discriminator",
"instance": "fsn1/waf",
"time": "2026-10-06T12:00:50.123461Z",
"event": "ban",
"host": "app.example",
@@ -79,9 +79,8 @@ on one line:
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above.
- `host`: the site of the request that raised the alert, since one
`discriminator` serves many sites. It is left out of `source_failure`,
`file_error` and `summary`.
- `host`: the site of the request that raised the alert, since one `waf` serves
many sites. It is left out of `source_failure`, `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
@@ -111,8 +110,8 @@ on one line:
## Slack and ntfy
Slack and ntfy get a message with a title and a text. The title is the instance
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
one line for each of these that the alert has:
and the event, such as `fsn1/waf: ban`. The text is the `reason`, then one line
for each of these that the alert has:
- the host, the client, the netblock and the country;
- the file, the source, the error and the mode from `detail`;
+5 -5
View File
@@ -1,9 +1,9 @@
# What the image holds
The contents of the `smallwebwaf` image, and how each part is pinned. The image
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
is pinned that way.
runs `rproxy` by default, or `waf` with `SWW_COMMAND=waf`. "Deployment" in
[`SPEC.md`](../SPEC.md) says why each part is there and why it is pinned that
way.
## Contents
@@ -18,8 +18,8 @@ is pinned that way.
- The `sww` binary, and the user `sww` (uid and gid 65532).
- The service directory `/etc/service/sww`, with the `run` script
`share/sww.run`.
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
the default rule file `share/rules.d/00-default.rules`.
- `/var/lib/sww` for `waf`'s state files, and `/etc/sww/rules.d` with the
default rule file `share/rules.d/00-default.rules`.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
## Pins
+6 -6
View File
@@ -1,8 +1,8 @@
# The metrics
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
keeps the `instance` label as `exported_instance` unless the scrape sets
What `GET /_sww/metrics` answers with, at `waf`'s own address. "Metrics
endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps
the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
@@ -15,7 +15,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
histograms;
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
`discriminator` is handling.
`waf` is handling.
## Limits and bans
@@ -78,8 +78,8 @@ By `file`:
## When a request is counted
`discriminator` counts a denied request at its decision, and a request to its
own endpoints when it is answered. It counts an allowed request when its report
`waf` counts a denied request at its decision, and a request to its own
endpoints when it is answered. It counts an allowed request when its report
comes, with the status, bytes and times the report gives. These are not counted:
- a report itself;
+34 -37
View File
@@ -1,32 +1,32 @@
# The request log's fields
The fields of the JSON objects `rproxy` and `discriminator` write about each
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
is written and how the log is sent to a syslog server. A field that does not
apply to a request is left out.
The fields of the JSON objects `rproxy` and `waf` write about each request.
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
written and how the log is sent to a syslog server. A field that does not apply
to a request is left out.
## `discriminator`'s lines
## `waf`'s lines
`sww discriminator` writes two lines for each question it answers, joined by
`request_id`. The field `discriminator_line` says which of the two a line is:
`sww waf` writes two lines for each question it answers, joined by `request_id`.
The field `waf_line` says which of the two a line is:
- `decision`: the line it writes once it has answered the question;
- `report`: the line it writes when `rproxy`'s report comes, with everything
known. The repetition is intended.
A request to its own endpoints writes one line, without `discriminator_line`. A
report writes no line but the `report` line of the request it names.
A request to its own endpoints writes one line, without `waf_line`. A report
writes no line but the `report` line of the request it names.
These questions write no line:
- a question refused with `400` (see "The question" under "`discriminator`" in
- a question refused with `400` (see "The question" under "`waf`" in
[`SPEC.md`](../SPEC.md));
- a question whose body stopped arriving, which gets no answer.
A `report` line:
```
{"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},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
{"type":"request","waf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/waf","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},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
```
### Every line
@@ -41,9 +41,8 @@ Every one of these lines has:
### Standard web log fields
- `time`: when `discriminator` had read the question's body, RFC 3339 with
milliseconds, in UTC. The `report` line gives the same time as the `decision`
line.
- `time`: when `waf` had read the question's body, RFC 3339 with milliseconds,
in UTC. The `report` line gives the same time as the `decision` line.
- `instance`: `SWW_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request.
@@ -52,9 +51,8 @@ Every one of these lines has:
- `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`.
- `status`:
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
the status of `deny`, or `0` for `close`, as the visitor is then sent
nothing;
- on the `decision` line, what `waf` answered: `200` for `allow`, the status
of `deny`, or `0` for `close`, as the visitor is then sent nothing;
- on the `report` line, what the visitor got, `0` if nothing.
- `request_bytes`:
- on the `decision` line, how much of the question's body the Core Rule Set
@@ -62,7 +60,7 @@ Every one of these lines has:
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
with what a WebSocket carried from the visitor.
- `response_bytes`:
- on the `decision` line, the body of `discriminator`'s answer;
- on the `decision` line, the body of `waf`'s answer;
- on the `report` line, the bytes of the answer's body, with what a
WebSocket carried from the app.
- `referer` and `user_agent`.
@@ -79,7 +77,7 @@ Every one of these lines has:
- lookups are off;
- the client is not looked up;
- the source cannot place the client;
- GeoJS had not answered by the time `discriminator` answered.
- GeoJS had not answered by the time `waf` answered.
- `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
@@ -89,7 +87,7 @@ Every one of these lines has:
### Response detail
- `response_content_type`: the type of `discriminator`'s own answer.
- `response_content_type`: the type of `waf`'s own answer.
- `aborted`: true when `rproxy` went away before the answer.
### Decision
@@ -105,8 +103,8 @@ Every one of these lines has:
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
`blocked_by_core_rule_set`: refused by that check;
- `request_header_too_large`: over a header limit;
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
`404` for a path under `/_sww/`.
- `own_endpoint`: answered at one of `waf`'s own endpoints, or `404` for a
path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally
`forward`.
@@ -131,9 +129,8 @@ Every one of these lines has:
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
or `byte_limits`.
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
- `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
@@ -191,8 +188,8 @@ Every one of these lines has:
All in milliseconds:
- `duration_total_ms`:
- on the `decision` line, from `discriminator` having read the question's
body to the line;
- on the `decision` line, from `waf` having read the question's body to the
line;
- on the `report` line, from `rproxy` having read the request's headers to
the end of the answer.
- `duration_checks_ms`: to the end of the checks;
@@ -202,8 +199,8 @@ All in milliseconds:
### Requests to its own endpoints
The line of a request to `discriminator`'s own endpoints has these fields, with
these meanings where they differ:
The line of a request to `waf`'s own endpoints has these fields, with these
meanings where they differ:
- `client_ip` and `peer_ip`: the request's TCP peer.
- `request_id`: a new random one of 26 letters and digits.
@@ -216,7 +213,7 @@ these meanings where they differ:
## `rproxy`'s line
`sww rproxy` writes one short line for each request. `request_id` joins it to
`discriminator`'s two. It has these fields and no others:
`waf`'s two. It has these fields and no others:
- `type`: `request`.
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
@@ -231,15 +228,15 @@ these meanings where they differ:
carried from the app.
- `duration_request_ms`: milliseconds from the request's headers being read
until the app had been sent the whole request, or the answer started if that
was sooner. It includes the wait for `discriminator`.
was sooner. It includes the wait for `waf`.
- `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
left out when:
- `discriminator` was not asked, as for the health check;
- the visitor went away while `discriminator` was being asked;
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
is `waf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left out
when:
- `waf` was not asked, as for the health check;
- the visitor went away while `waf` was being asked;
- `waf`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out.
+4 -6
View File
@@ -54,20 +54,18 @@ empty-agent user_agent log ^$
## A file of your own
`discriminator` reads the rule files in its own container. Rules of your own go
in a file named to sort after the default, such as `50-site.rules`:
`waf` reads the rule files in its own container. Rules of your own go in a file
named to sort after the default, such as `50-site.rules`:
```
# 50-site.rules: WordPress probes, which this site never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
```
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
from, adds it:
A `Dockerfile` built on the `smallwebwaf` image, for `waf` to run from, adds it:
```dockerfile
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
```
Or mount it there. A `path` rule applies on every site behind that
`discriminator`.
Or mount it there. A `path` rule applies on every site behind that `waf`.
+37 -40
View File
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWW_` are `discriminator`'s. Those that start with
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
between the two (see "The image's command" below).
The settings that start with `SWW_` are `waf`'s. Those that start with `SWRP_`
are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses between
the two (see "The image's command" below).
## Forms of values
@@ -28,21 +28,20 @@ between the two (see "The image's command" below).
## Core
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
port, such as `:9000`. Leave the host out (see "Ports" in
[`SPEC.md`](../SPEC.md)).
- `SWW_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port, such
as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
in each line `discriminator` logs, and in each alert and metric, such as
`fsn1/discriminator`. Valid UTF-8. No control character while
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
in each line `waf` logs, and in each alert and metric, such as `fsn1/waf`.
Valid UTF-8. No control character while `SWW_ALERT_NTFY_URL` is set. 1 to 48
printable ASCII characters without a space while `SWW_LOG_REMOTE_URL` is set
and `SWW_LOG_REMOTE_APP_NAME` is not.
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
group, from 32 to 128.
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero.
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
keeps, a whole number above zero.
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a whole
number above zero.
## Tokens
@@ -62,8 +61,8 @@ between the two (see "The image's command" below).
## Logging
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
messages that are written: `debug`, `info`, `warn` or `error`.
- `SWW_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
that are written: `debug`, `info`, `warn` or `error`.
- `SWW_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the
request headers recorded in the log line, in either case. `Host` and
@@ -167,8 +166,8 @@ cut off; the client is banned.
## Request tags
Every request gets tags from what `discriminator` knows of its client. See
"Request tags" in [`SPEC.md`](../SPEC.md).
Every request gets tags from what `waf` knows of its client. See "Request tags"
in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
@@ -188,10 +187,10 @@ Every request gets tags from what `discriminator` knows of its client. See
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS.
`discriminator` asks it about each client, in the background, and adds the
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
unset, nothing asks routewatch and no request gets a `prefix=` tag.
service that gives an address's announced netblock and origin AS. `waf` asks
it about each client, in the background, and adds the tags `asn=<number>` and
`prefix=<netblock>` from its answer. While it is unset, nothing asks
routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these:
@@ -377,7 +376,7 @@ Then mount its directory into the container read-only, and name the file:
```sh
docker run \
-v /srv/app/tokens:/etc/sww/tokens:ro \
-e SWW_COMMAND=discriminator \
-e SWW_COMMAND=waf \
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
...
```
@@ -391,21 +390,20 @@ A few limits are fixed rather than settings:
- at most 1,000 DNS blocklist queries are under way at once;
- at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts;
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
- `discriminator` refuses a request with more than 50 cookies or more than 100
header fields (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md));
- `waf` holds at most 10,000 answered requests awaiting their reports;
- `waf` refuses a request with more than 50 cookies or more than 100 header
fields (see "Answering in time" under "`waf`" in [`SPEC.md`](../SPEC.md));
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
kept-open connection idle for 120 seconds;
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
answers `502` to a longer one.
- `rproxy` reads at most 1 MiB of the body of `waf`'s answer, and answers `502`
to a longer one.
## `discriminator`'s answer and `rproxy`'s wait
## `waf`'s answer and `rproxy`'s wait
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
in the background (see "Answering in time" under "`discriminator`" in
in the background (see "Answering in time" under "`waf`" in
[`SPEC.md`](../SPEC.md)).
## `rproxy`
@@ -417,16 +415,15 @@ they do.
A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
`discriminator`'s answer, to a question and to a report. A time; never `off`.
- `SWRP_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or `https`
URL with a host and an optional port, and nothing more. `rproxy` speaks
HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
`discriminator` cannot be connected to, does not answer at all, or does not
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
answers `503`. It does not apply to an answer `discriminator` gives, such as
an error.
`waf` cannot be connected to, does not answer at all, or does not answer
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
`503`. It does not apply to an answer `waf` gives, such as an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
@@ -452,8 +449,8 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
## The image's command
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
check checks: `rproxy` or `discriminator` (see "Deployment" in
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
check checks: `rproxy` or `waf` (see "Deployment" in [`SPEC.md`](../SPEC.md)).
It cannot be given as a file.
## Where earlier settings went
+1 -1
View File
@@ -7,7 +7,7 @@ a top-level `version` of 1.
## `bans.json`
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
`discriminator` made. Per entry:
`waf` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
+1 -1
View File
@@ -151,7 +151,7 @@ type Params struct {
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
// Country are, for a ban, the client's as the ban's notes give them. Host is
// the site of the request that raised the alert, while one discriminator serves
// the site of the request that raised the alert, while one waf serves
// many sites, and "" otherwise.
//
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
+13 -13
View File
@@ -1,6 +1,6 @@
// Package config reads sww's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of `sww
// discriminator` start with SWW_, every one of them has a default, and most can be
// waf` start with SWW_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
@@ -32,10 +32,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog"
)
// Config is the settings of `sww discriminator`. A time or size limit of
// Config is the settings of `sww waf`. A time or size limit of
// zero is off.
type Config struct {
// ListenAddr is where discriminator listens (SWW_LISTEN_ADDR).
// ListenAddr is where waf listens (SWW_LISTEN_ADDR).
ListenAddr string
// InstanceName is the name every log line and alert gives as instance,
// and every metric carries as its label instance (SWW_INSTANCE_NAME),
@@ -239,7 +239,7 @@ type Config struct {
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute
// that discriminator may refuse after a rule file or Core Rule Set match; one
// that waf may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64
// LogRemoteURL is where every line on stdout is also sent
@@ -378,8 +378,8 @@ var (
errNeedsDBPath = errors.New("it names the file to look clients up in")
errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New(
"is more than 64K, the most discriminator gets of a body")
errNotCommand = errors.New("is not rproxy or discriminator")
"is more than 64K, the most waf gets of a body")
errNotCommand = errors.New("is not rproxy or waf")
errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New(
@@ -627,7 +627,7 @@ func FromEnvironment(
}
// Command reads only SWW_COMMAND, the command the image runs and its
// health check checks: rproxy when it is unset or empty, or discriminator. It cannot
// health check checks: rproxy when it is unset or empty, or waf. It cannot
// be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) {
command, _ := lookupEnv("SWW_COMMAND")
@@ -635,17 +635,17 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
switch command {
case "", "rproxy":
return "rproxy", nil
case "discriminator":
case "waf":
return command, nil
default:
return "", fmt.Errorf("SWW_COMMAND: %q %w", command, errNotCommand)
}
}
// DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in discriminator's container needs
// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in waf's container needs
// no other setting, so it reads no other, nor a file that another names.
func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWW_LISTEN_ADDR")
@@ -1610,7 +1610,7 @@ func parseSize(value string) (int64, error) {
}
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 64K, since discriminator
// reads: a size as parseSize reads it, or off, but at most 64K, since waf
// gets no more of a body than that.
func parseWAFBodyLimit(value string) (int64, error) {
limit, err := parseSize(value)
@@ -1955,7 +1955,7 @@ func ParseASN(value string) (string, error) {
return "AS" + strconv.FormatUint(number, 10), nil
}
// The tags a request can get that the settings can name, as discriminator
// The tags a request can get that the settings can name, as waf
// writes them. The prefixes are followed by a value.
const (
ASNTagPrefix = "asn="
+2 -2
View File
@@ -636,11 +636,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
},
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
{wafBodyLimit, "65K", `"65K" is more than 64K, the most waf ` +
`gets of a body`},
{
wafBodyLimit, "65537",
`"65537" is more than 64K, the most discriminator gets of a body`,
`"65537" is more than 64K, the most waf gets of a body`,
},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
+6 -6
View File
@@ -22,12 +22,12 @@ type RProxy struct {
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
// WAFBackend is waf's URL (SWRP_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for discriminator's answer to a question,
// Timeout is how long rproxy waits for waf's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request when discriminator cannot be
// DefaultDisposition is what is done with a request when waf cannot be
// connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION).
DefaultDisposition string
@@ -50,7 +50,7 @@ type RProxy struct {
var (
errNeedsWAFBackend = errors.New(
"is required: it is discriminator's URL, such as http://discriminator:8080")
"is required: it is waf's URL, such as http://waf:8080")
errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
return slog.GroupValue(c.settings...)
}
// wafBackend reads the setting that is discriminator's URL, which must be set.
// wafBackend reads the setting that is waf's URL, which must be set.
func (e *environment) wafBackend(name string) *url.URL {
value := e.value(name, "")
if value == "" {
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
return backend
}
// disposition reads the setting that is what is done with a request when discriminator
// disposition reads the setting that is what is done with a request when waf
// cannot be connected to, does not answer at all, or does not answer in time:
// allow or deny.
func (e *environment) disposition(name, defaultValue string) string {
+3 -3
View File
@@ -24,7 +24,7 @@ const (
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
backendURL = "http://discriminator:8080"
backendURL = "http://waf:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot"
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
name, value string
}{
{wafBackend, ""},
{wafBackend, "discriminator:8080"},
{wafBackend, "http://discriminator:8080/path"},
{wafBackend, "waf:8080"},
{wafBackend, "http://waf:8080/path"},
{rproxyTimeout, "0"},
{rproxyTimeout, "-1"},
{rproxyTimeout, off},
+2 -2
View File
@@ -92,7 +92,7 @@ type History struct {
ASName string `json:"as_name,omitempty"`
Country string `json:"country,omitempty"`
LookedUp time.Time `json:"looked_up,omitzero"`
// Requests are all the client's requests: Forwarded those discriminator allowed,
// Requests are all the client's requests: Forwarded those waf allowed,
// Refused those it refused, and neither those for a path under
// /_sww/.
Requests int64 `json:"requests"`
@@ -136,7 +136,7 @@ type Offences struct {
// Request is what a client's history keeps of one of its requests.
type Request struct {
// Forwarded is true for a request discriminator allowed, and Refused for one it
// Forwarded is true for a request waf allowed, and Refused for one it
// refused. Both are false for a request for a path under /_sww/.
Forwarded bool
Refused bool
+18 -18
View File
@@ -1,5 +1,5 @@
// Package requestlog writes the lines sww prints on stdout: one
// JSON object per request, two from discriminator and a short one from rproxy,
// JSON object per request, two from waf and a short one from rproxy,
// marked "type":"request", and the process's own messages as JSON lines marked
// "type":"process".
package requestlog
@@ -14,9 +14,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
)
// The action a request line names: what discriminator did with the request.
// The action a request line names: what waf did with the request.
const (
// ActionForward is a request discriminator allowed, which rproxy passes to
// ActionForward is a request waf allowed, which rproxy passes to
// the app.
ActionForward = "forward"
@@ -55,7 +55,7 @@ const (
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
// ActionOwnEndpoint is a request discriminator answered at one of its own
// ActionOwnEndpoint is a request waf answered at one of its own
// endpoints, or a question about a path under /_sww/.
ActionOwnEndpoint = "own_endpoint"
)
@@ -69,14 +69,14 @@ const OffenceLimit = "limit"
// that broke the error burst.
const LimitHitMinuteRefusals = "minute_refusals"
// The discriminator_line a request line names, which says which of
// discriminator's two lines about a question it is.
// The waf_line a request line names, which says which of
// waf's two lines about a question it is.
const (
// DiscriminatorLineDecision is discriminator's line at its decision.
DiscriminatorLineDecision = "decision"
// DiscriminatorLineReport is discriminator's line when rproxy's report
// WAFLineDecision is waf's line at its decision.
WAFLineDecision = "decision"
// WAFLineReport is waf's line when rproxy's report
// comes, with the decision's fields and the report's together.
DiscriminatorLineReport = "report"
WAFLineReport = "report"
)
// timeLayout is RFC 3339 with milliseconds.
@@ -89,9 +89,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Line struct {
Type string `json:"type"`
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
// on discriminator's two lines about a question.
DiscriminatorLine string `json:"discriminator_line,omitempty"`
// WAFLine is WAFLineDecision or WAFLineReport
// on waf's two lines about a question.
WAFLine string `json:"waf_line,omitempty"`
// The standard web log fields. Scheme is how the client reached rproxy, or
// the trusted proxy in front of it.
@@ -111,7 +111,7 @@ type Line struct {
UserAgent string `json:"user_agent"`
// Request detail. RequestID is the id rproxy made for the request, or for a
// request to discriminator's own endpoints a new one. ForwardedFor is the
// request to waf's own endpoints a new one. ForwardedFor is the
// X-Forwarded-For header as received. ClientGroup is the netblock the
// client is counted as. ASN, ASName and Country are the client's AS
// number, AS name and country, as looked up.
@@ -131,7 +131,7 @@ type Line struct {
HasAuthorization bool `json:"has_authorization,omitempty"`
HasCookie bool `json:"has_cookie,omitempty"`
// Response detail, of discriminator's own answer. Aborted is true when
// Response detail, of waf's own answer. Aborted is true when
// rproxy, or the admin's tool, went away before the answer.
ResponseContentType string `json:"response_content_type,omitempty"`
Aborted bool `json:"aborted,omitempty"`
@@ -142,7 +142,7 @@ type Line struct {
// taken with a request it would have refused: ActionClientDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
// ActionBlockedByCoreRuleSet, or, in discriminator,
// ActionBlockedByCoreRuleSet, or, in waf,
// ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
@@ -151,7 +151,7 @@ type Line struct {
Heuristic string `json:"heuristic,omitempty"`
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
// the heuristic that banned the client after discriminator answered the
// the heuristic that banned the client after waf answered the
// request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
@@ -231,7 +231,7 @@ type RProxyLine struct {
// sooner. DurationResponseMS runs from then until the answer ends.
DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition discriminator answered or, while
// Disposition is the valid disposition waf answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
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
// any IPv6 zone, which discriminator refuses.
// any IPv6 zone, which waf refuses.
func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
@@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr {
// address outside them is the visitor; if every address in it is inside, the
// leftmost is, and with no header, the peer. An entry that is not an address
// ends the reading, since nothing to its left can be believed. The visitor's
// address never has an IPv6 zone, which discriminator refuses.
// address never has an IPv6 zone, which waf refuses.
func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr {
+35 -35
View File
@@ -17,9 +17,9 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// post is the start of a POST whose headers end with what follows it.
@@ -59,7 +59,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
}
}
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -72,7 +72,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
@@ -101,7 +101,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: "1K",
@@ -110,7 +110,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, 1025))))
if got.status != http.StatusForbidden {
t.Errorf("answered %+v, want discriminator's 403", got)
t.Errorf("answered %+v, want waf's 403", got)
}
}
@@ -133,7 +133,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
var asked atomic.Bool
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
asked.Store(true)
greet(w, r)
@@ -166,7 +166,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Run("announced, is refused", func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
_, _ = io.WriteString(w, large)
@@ -188,7 +188,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Run("not announced, is cut off", func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, large[:1])
_ = http.NewResponseController(w).Flush()
@@ -218,7 +218,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
const announced = "2048"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, announced)
})
@@ -241,7 +241,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
t.Run(setting, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
setting: "off",
@@ -252,7 +252,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader("a body"))))
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
if got := get(t, addr, waf.HealthPath); got.status != http.StatusOK {
t.Errorf("answered %+v, want rproxy's own 200", got)
}
})
@@ -271,7 +271,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "500",
@@ -290,7 +290,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
t.Error("waf was asked")
}
standIn.wantNoReport(t)
@@ -301,7 +301,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -315,7 +315,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
t.Error("waf was asked")
}
}
@@ -335,7 +335,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -366,7 +366,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
t.Error("waf was asked")
}
})
}
@@ -412,7 +412,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
announced = 100 * questionBodyMaxBytes
)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
@@ -456,13 +456,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
name, target string
status int
}{
{"discriminator's deny", "/", http.StatusForbidden},
{"waf's deny", "/", http.StatusForbidden},
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
@@ -494,7 +494,7 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) {
const least = 10 * time.Second
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "300",
@@ -516,7 +516,7 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) {
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
@@ -585,7 +585,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
writeSize = 32 << 10
)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
map[string]string{maxRequestTime: "3000"})
@@ -612,7 +612,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Parallel()
release := make(chan struct{})
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
<-release
})
@@ -658,7 +658,7 @@ func visitAsTheTimeRunsOut(t *testing.T) {
// answerAfter is how long the app waits before it answers.
var answerAfter atomic.Int64
backend := startDiscriminatorAnsweringReports(t, allow,
backend := startWAFAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
@@ -701,7 +701,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
const start = "the start"
release := make(chan struct{})
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, start)
_ = http.NewResponseController(w).Flush()
@@ -750,7 +750,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
writeSize = 32 << 10
)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
part := make([]byte, writeSize)
@@ -787,7 +787,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/")
@@ -803,7 +803,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting)
})
@@ -867,7 +867,7 @@ func readAnswer(t *testing.T, conn net.Conn) answered {
func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, answerBeforeTheBody)
// The request time limit is longer than the 10 seconds the status and headers
// of an answer have to be sent, and than dial's deadline.
@@ -887,7 +887,7 @@ func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.
const limit = 2 * time.Second
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, answerBeforeTheBody)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
@@ -914,7 +914,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
t.Parallel()
working := make(chan struct{})
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
close(working)
<-r.Context().Done()
@@ -955,7 +955,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
const answer = "the end of a long answer"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = http.NewResponseController(w).Flush()
@@ -984,14 +984,14 @@ func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T
{"a deny", post, http.StatusForbidden},
{
"the health check",
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
"GET " + waf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
http.StatusOK,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
// The request time limit is longer than the 10 seconds an answer rproxy
// gives itself has to be sent, and than dial's deadline.
+34 -34
View File
@@ -12,11 +12,11 @@ import (
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// questionBodyMaxBytes is the most of a body a question carries.
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
const questionBodyMaxBytes = waf.QuestionBodyMaxBytes
// chunked is the encoding of a body whose length the visitor did not announce.
const chunked = "chunked"
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set(forwardedProtoHeader, "gopher")
req.Header.Set(requestIDHeader, "the-visitor's")
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
req.Header.Set(discriminator.ContentLengthHeader, "12345")
req.Header.Set(waf.ClientHeader, "198.51.100.2")
req.Header.Set(waf.PeerHeader, "198.51.100.3")
req.Header.Set(waf.ContentLengthHeader, "12345")
// Go's client then sends no User-Agent, so that one in the question would be
// Go's own.
req.Header["User-Agent"] = nil
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
want.Set(forwardedForHeader, "198.51.100.1")
want.Set(forwardedProtoHeader, "http")
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
want.Set(discriminator.ClientHeader, localhost)
want.Set(discriminator.PeerHeader, localhost)
want.Set(waf.ClientHeader, localhost)
want.Set(waf.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf("the question's headers are %v, want %v", asked.header, want)
@@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
forwarded := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Header.Clone()
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
wantGreeting(t, visit(t, req))
asked := standIn.question(t)
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(discriminator.PeerHeader) != localhost ||
if asked.header.Get(waf.ClientHeader) != tc.client ||
asked.header.Get(waf.PeerHeader) != localhost ||
asked.header.Get(forwardedForHeader) != visitor ||
asked.header.Get(forwardedProtoHeader) != tc.proto {
t.Errorf("the question's headers are %v", asked.header)
@@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8",
@@ -167,7 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
req.Header[forwardedForHeader] = tc.header
wantGreeting(t, visit(t, req))
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
if got := standIn.question(t).header.Get(waf.ClientHeader); got !=
tc.client {
t.Errorf("the question names %s, want %s", got, tc.client)
}
@@ -183,7 +183,7 @@ func randomBytes(n int) []byte {
return body
}
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel()
for _, size := range []int{
@@ -198,7 +198,7 @@ func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel()
body := randomBytes(size)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
received := make(chan []byte, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
whole, _ := io.ReadAll(r.Body)
@@ -244,7 +244,7 @@ func wantQuestionBody(
len(want))
}
values := asked.header.Values(discriminator.ContentLengthHeader)
values := asked.header.Values(waf.ContentLengthHeader)
if announced {
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
@@ -265,7 +265,7 @@ func wantQuestionBody(
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -276,7 +276,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", status)
t.Errorf("status %d, want waf's 403", status)
}
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
@@ -307,14 +307,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
t.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
conn := dial(t, addr)
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", status)
t.Errorf("status %d, want waf's 403", status)
}
asked := standIn.question(t)
@@ -335,7 +335,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Run(method, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
@@ -356,7 +356,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
t.Error("waf was asked")
}
standIn.wantNoReport(t)
@@ -364,7 +364,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
}
}
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
func TestEachMethodPassedOnReachesWAFAndTheApp(t *testing.T) {
t.Parallel()
for _, method := range []string{
@@ -377,7 +377,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
t.Parallel()
reached := make(chan string, 1)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
reached <- r.Method
})
@@ -389,7 +389,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
}
if asked := standIn.question(t); asked.method != method {
t.Errorf("discriminator was asked about %s", asked.method)
t.Errorf("waf was asked about %s", asked.method)
}
if atApp := <-reached; atApp != method {
@@ -399,7 +399,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
}
}
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
t.Parallel()
const host = "Host: site.example\r\n\r\n"
@@ -421,7 +421,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -439,7 +439,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
t.Error("waf was asked")
}
standIn.wantNoReport(t)
@@ -447,12 +447,12 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
}
}
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
func TestAHostWithAPercentSignButNoBracketGoesToWAF(t *testing.T) {
t.Parallel()
const host = "a%b"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -473,7 +473,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
const want = "http"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
@@ -502,7 +502,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
@@ -515,8 +515,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
asked := standIn.question(t)
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
if asked.header.Get(waf.ClientHeader) != tc.client ||
asked.header.Get(waf.PeerHeader) != tc.peer {
t.Errorf("the question's headers are %v", asked.header)
}
+11 -11
View File
@@ -16,16 +16,16 @@ import (
"sync/atomic"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
// soon as it arrives.
const flushAfterEachWrite time.Duration = -1
// reportStatusMax is the highest status discriminator takes in a report. An app can
// reportStatusMax is the highest status waf takes in a report. An app can
// send a higher one, up to 999, and the visitor gets that as it is.
const reportStatusMax = 599
@@ -60,14 +60,14 @@ type request struct {
peerTrusted bool
start time.Time
// disposition is what is done with the request: discriminator's valid disposition,
// disposition is what is done with the request: waf's valid disposition,
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string
// answered is true when discriminator gave disposition, which makes a report due,
// answered is true when waf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
// app.
asn, country string
@@ -131,14 +131,14 @@ func (rq *request) answerHealth() {
// visitor too slow to send it is answered 408. For a malformed chunk, or a
// visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
// read, so that discriminator is asked first; passToApp then refuses it.
// read, so that waf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil {
return true
}
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
held, err := io.ReadAll(io.LimitReader(rq.body, waf.QuestionBodyMaxBytes))
// From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{})
@@ -200,7 +200,7 @@ func (rq *request) forward(ctx context.Context) {
// rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
// discriminator's.
// waf's.
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
upstream := rq.h.config.UpstreamURL
pr.Out.URL.Scheme = upstream.Scheme
@@ -333,7 +333,7 @@ func (rq *request) answer(status int) {
// handler has returned, after finish has written the log line and sent the report.
// Without Content-Length, the flush would send the body in chunks, the last of
// which would still wait for the handler. A HEAD's answer has no body, so any
// Content-Length it has stays as discriminator gave it.
// Content-Length it has stays as waf gave it.
func (rq *request) send(status int, body []byte) {
if rq.in.Method != http.MethodHead {
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
@@ -379,7 +379,7 @@ func (rq *request) refuse(status int) {
}
}
// finish ends the request's time limits, writes its log line and, if discriminator
// finish ends the request's time limits, writes its log line and, if waf
// answered the question, sends the report. ctx is the request's own context.
func (rq *request) finish(ctx context.Context) {
rq.stopTimers()
@@ -416,7 +416,7 @@ func (rq *request) finish(ctx context.Context) {
return
}
report := &discriminator.Report{
report := &waf.Report{
RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes,
+28 -28
View File
@@ -1,10 +1,10 @@
// Package rproxy is the reverse proxy in front of one app that asks discriminator
// about each request. It passes a request discriminator allows to the app and the
// Package rproxy is the reverse proxy in front of one app that asks waf
// about each request. It passes a request waf allows to the app and the
// app's answer back, within the size and time limits. Any other answer from
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
// decides only when discriminator cannot be connected to, does not answer at all, or
// waf goes to the visitor as waf gave it. SWRP_DEFAULT_DISPOSITION
// decides only when waf cannot be connected to, does not answer at all, or
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to discriminator as the visitor
// method it does not pass on, or one it cannot put to waf as the visitor
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
// SPEC.md says what it does.
package rproxy
@@ -22,10 +22,10 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// How rproxy keeps connections to the app and to discriminator open between
// How rproxy keeps connections to the app and to waf open between
// requests.
const (
idleConns = 100
@@ -80,14 +80,14 @@ func New(params Params) *Server {
backend := params.Config.WAFBackend
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
h := &handler{
config: params.Config,
requestLog: params.RequestLog,
processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
discriminatorTransport: newDiscriminatorTransport(),
config: params.Config,
requestLog: params.RequestLog,
processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
wafTransport: newWAFTransport(),
reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
Scheme: backend.Scheme, Host: backend.Host, Path: waf.ReportPath,
}).String(),
}
@@ -122,10 +122,10 @@ type handler struct {
requestLog io.Writer
processLog *slog.Logger
errorLog *log.Logger
// transport carries requests to the app, and discriminatorTransport questions and
// reports to discriminator.
transport http.RoundTripper
discriminatorTransport http.RoundTripper
// transport carries requests to the app, and wafTransport questions and
// reports to waf.
transport http.RoundTripper
wafTransport http.RoundTripper
// reportURL is where the reports go.
reportURL string
// reports waits for the reports being sent. reportsMu guards starting one
@@ -147,11 +147,11 @@ func newTransport() *http.Transport {
}
}
// newDiscriminatorTransport returns what carries questions and reports to
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
// newWAFTransport returns what carries questions and reports to
// waf. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
// a visitor's Upgrade header, or a Connection header other than close or
// keep-alive, and that would read as discriminator giving no answer.
func newDiscriminatorTransport() *http.Transport {
// keep-alive, and that would read as waf giving no answer.
func newWAFTransport() *http.Transport {
transport := newTransport()
transport.Protocols = new(http.Protocols)
transport.Protocols.SetHTTP1(true)
@@ -160,15 +160,15 @@ func newDiscriminatorTransport() *http.Transport {
}
// ServeHTTP handles one request: it answers the health check itself, refuses a
// request with a method it does not pass on or one it cannot put to discriminator as
// request with a method it does not pass on or one it cannot put to waf as
// the visitor sent it, and for any other request reads the first part of the
// body, asks discriminator, and does what the answer says. Once the answer has ended,
// body, asks waf, and does what the answer says. Once the answer has ended,
// it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r)
defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
rq.answerHealth()
return
@@ -201,9 +201,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq.noteAnswer(answer)
switch rq.disposition {
case discriminator.DispositionAllow:
case waf.DispositionAllow:
rq.passToApp(r.Context())
case discriminator.DispositionClose:
case waf.DispositionClose:
// This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler)
@@ -213,7 +213,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. discriminator answers a path under ownEndpointPrefix itself, so
// it from the app. waf answers a path under ownEndpointPrefix itself, so
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context.
func (rq *request) passToApp(ctx context.Context) {
+101 -101
View File
@@ -19,9 +19,9 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -54,27 +54,27 @@ const (
// The headers the tests set and look at.
const (
dispositionHeader = discriminator.DispositionHeader
dispositionHeader = waf.DispositionHeader
forwardedForHeader = "X-Forwarded-For"
forwardedProtoHeader = "X-Forwarded-Proto"
requestIDHeader = "X-Request-ID"
asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country"
contentLengthHeader = "Content-Length"
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
fromDiscriminatorHeader = "X-From-Discriminator"
// fromWAFHeader is a header the stand-in for waf adds to a deny.
fromWAFHeader = "X-From-Waf"
// addedHeaderValue is the value of a header a test adds to an answer, to see it
// reach the visitor.
addedHeaderValue = "yes"
)
// What the stand-in for discriminator answers.
// What the stand-in for waf answers.
const (
dispositionAllow = discriminator.DispositionAllow
dispositionDeny = discriminator.DispositionDeny
dispositionClose = discriminator.DispositionClose
dispositionAllow = waf.DispositionAllow
dispositionDeny = waf.DispositionDeny
dispositionClose = waf.DispositionClose
// deniedBody is the body of a deny.
deniedBody = "refused by discriminator"
deniedBody = "refused by waf"
)
// output collects what rproxy writes on stdout.
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
return len(p), nil
}
// question is what the stand-in for discriminator received as a question.
// question is what the stand-in for waf received as a question.
type question struct {
method, target, host string
header http.Header
@@ -167,26 +167,26 @@ type question struct {
transferEncoding []string
}
// Discriminator is a stand-in for discriminator that answers each question as
// WAF is a stand-in for waf that answers each question as
// answer does, and keeps the questions and the reports it gets.
type Discriminator struct {
type WAF struct {
*httptest.Server
questions chan question
reports chan discriminator.Report
reports chan waf.Report
}
// startDiscriminator starts a stand-in for discriminator.
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
// startWAF starts a stand-in for waf.
func startWAF(t *testing.T, answer http.HandlerFunc) *WAF {
t.Helper()
s := &Discriminator{
s := &WAF{
questions: make(chan question, 10),
reports: make(chan discriminator.Report, 10),
reports: make(chan waf.Report, 10),
}
s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) == "" {
if r.Header.Get(waf.ClientHeader) == "" {
s.takeReport(t, w, r)
return
@@ -209,17 +209,17 @@ func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
return s
}
// takeReport keeps a report and answers it as discriminator does.
func (s *Discriminator) takeReport(
// takeReport keeps a report and answers it as waf does.
func (s *WAF) takeReport(
t *testing.T, w http.ResponseWriter, r *http.Request,
) {
t.Helper()
var report discriminator.Report
var report waf.Report
err := json.NewDecoder(r.Body).Decode(&report)
if err != nil || r.Method != http.MethodPost ||
r.URL.Path != discriminator.ReportPath {
r.URL.Path != waf.ReportPath {
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
}
@@ -229,7 +229,7 @@ func (s *Discriminator) takeReport(
}
// wantNoReport checks that no report comes for a moment.
func (s *Discriminator) wantNoReport(t *testing.T) {
func (s *WAF) wantNoReport(t *testing.T) {
t.Helper()
select {
@@ -240,7 +240,7 @@ func (s *Discriminator) wantNoReport(t *testing.T) {
}
// report waits for a report.
func (s *Discriminator) report(t *testing.T) discriminator.Report {
func (s *WAF) report(t *testing.T) waf.Report {
t.Helper()
select {
@@ -249,12 +249,12 @@ func (s *Discriminator) report(t *testing.T) discriminator.Report {
case <-time.After(waitLimit):
t.Fatal("no report")
return discriminator.Report{}
return waf.Report{}
}
}
// question waits for a question.
func (s *Discriminator) question(t *testing.T) question {
func (s *WAF) question(t *testing.T) question {
t.Helper()
select {
@@ -267,17 +267,17 @@ func (s *Discriminator) question(t *testing.T) question {
}
}
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
// startWAFAnsweringReports starts a stand-in for waf that
// answers each question as answer does and each report as report does, and
// returns its URL.
func startDiscriminatorAnsweringReports(
func startWAFAnsweringReports(
t *testing.T, answer, report http.HandlerFunc,
) string {
t.Helper()
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
if r.Header.Get(waf.ClientHeader) != "" {
answer(w, r)
return
@@ -299,7 +299,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
// deny answers a question with deny, status 403 and a body of its own.
func deny(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.Header().Set(fromWAFHeader, addedHeaderValue)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, deniedBody)
}
@@ -320,7 +320,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, greeting)
}
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
// startRProxy starts rproxy with waf at backend and the app at appURL, and
// the settings in env on top of the defaults. It returns where rproxy listens
// and what it writes.
func startRProxy(
@@ -502,7 +502,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(asnHeader, "AS64496")
w.Header().Set(countryHeader, "DE")
allow(w, r)
@@ -561,7 +561,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
}
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
@@ -606,7 +606,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
@@ -645,7 +645,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>")
@@ -667,19 +667,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
}
}
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
func TestRemovesTheVisitorsLookupHeadersWhenWAFGivesNone(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
backend func(t *testing.T) string
}{
{"discriminator allows", func(t *testing.T) string {
{"waf allows", func(t *testing.T) string {
t.Helper()
return startDiscriminator(t, allow).URL
return startWAF(t, allow).URL
}},
{"discriminator is down", func(*testing.T) string { return nowhere }},
{"waf is down", func(*testing.T) string { return nowhere }},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -705,10 +705,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T)
}
}
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
@@ -716,8 +716,8 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
got := get(t, addr, "/")
if got.status != http.StatusForbidden || got.body != deniedBody ||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want discriminator's answer", got)
got.header.Get(fromWAFHeader) != addedHeaderValue {
t.Errorf("answered %+v, want waf's answer", got)
}
if len(got.header.Values(dispositionHeader)) != 0 {
@@ -733,21 +733,21 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
t.Errorf("line %v, want discriminator's deny", line)
t.Errorf("line %v, want waf's deny", line)
}
}
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
func TestADeniedHeadKeepsWAFsContentLength(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusForbidden ||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
t.Errorf("answered %+v, want waf's 403 and its length", got)
}
}
@@ -762,13 +762,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
}{
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
{"the health check", "GET " + waf.HealthPath + host, "ok\n", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
log := heldLog{release: make(chan struct{})}
standIn := startDiscriminator(t, deny)
standIn := startWAF(t, deny)
app := startApp(t, greet)
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
@@ -798,7 +798,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
@@ -821,7 +821,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK)
})
@@ -853,7 +853,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t,
backend := startWAFAnsweringReports(t,
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
},
@@ -878,17 +878,17 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
}
}
// answeringWith returns what starts a stand-in for discriminator that answers each
// answeringWith returns what starts a stand-in for waf that answers each
// question as answer does, and returns its URL.
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string {
t.Helper()
return startDiscriminator(t, answer).URL
return startWAF(t, answer).URL
}
}
// noAnswers are the ways discriminator can leave the decision to the default.
// noAnswers are the ways waf can leave the decision to the default.
func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere },
@@ -920,7 +920,7 @@ func noAnswers() map[string]func(t *testing.T) string {
}
}
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -929,7 +929,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
}{
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
{"waf's 400", "", "refused\n", http.StatusBadRequest},
{"a 500", "", "broken\n", http.StatusInternalServerError},
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
{"no header", "", "who knows\n", http.StatusOK},
@@ -939,12 +939,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
if tc.disposition != "" {
w.Header().Set(dispositionHeader, tc.disposition)
}
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
w.Header().Set(fromWAFHeader, addedHeaderValue)
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
})
@@ -957,8 +957,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
got := get(t, addr, "/")
if got.status != tc.status || got.body != tc.body ||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
t.Errorf("answered %+v, want discriminator's answer", got)
got.header.Get(fromWAFHeader) != addedHeaderValue {
t.Errorf("answered %+v, want waf's answer", got)
}
if len(got.header.Values(dispositionHeader)) != 0 {
@@ -970,8 +970,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
t.Errorf("line %v, want status %d and no default", line, tc.status)
}
if out.has("discriminator gave no answer") {
t.Error("rproxy warned that discriminator gave no answer")
if out.has("waf gave no answer") {
t.Error("rproxy warned that waf gave no answer")
}
})
}
@@ -981,14 +981,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
t.Errorf("answered %+v, want discriminator's 400", got)
t.Errorf("answered %+v, want waf's 400", got)
}
if line := out.requestLine(t); line["disposition"] != nil {
@@ -1001,7 +1001,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "<html>broken")
@@ -1019,7 +1019,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
const size = 1 << 20
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size))
})
@@ -1027,15 +1027,15 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size {
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
t.Errorf("answered %d with %d bytes, want waf's 500 with %d",
got.status, len(got.body), size)
}
}
// startDiscriminatorWriting starts a stand-in for discriminator that reads
// startWAFWriting starts a stand-in for waf that reads
// each question and answers it with text, as it is, and returns its URL. It
// can send what Go's server would not.
func startDiscriminatorWriting(t *testing.T, text string) string {
func startWAFWriting(t *testing.T, text string) string {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
@@ -1105,7 +1105,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
"a status below 100": func(t *testing.T) string {
t.Helper()
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
return startWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
},
"a 101 that holds its connection open": answeringWith(switchAndHold),
} {
@@ -1130,7 +1130,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
t.Errorf("line %v, want status 502 and no disposition", line)
}
out.waitFor(t, "discriminator's answer is dropped")
out.waitFor(t, "waf's answer is dropped")
})
}
}
@@ -1139,7 +1139,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
standIn := startWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
@@ -1196,14 +1196,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
}
}
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
@@ -1226,8 +1226,8 @@ func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T
t.Errorf("line %v, want status 0 and no disposition", line)
}
if out.has("discriminator gave no answer") {
t.Error("rproxy warned that discriminator gave no answer")
if out.has("waf gave no answer") {
t.Error("rproxy warned that waf gave no answer")
}
standIn.wantNoReport(t)
@@ -1242,13 +1242,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
addr, out := startRProxy(t, nowhere, app.URL, nil)
wantGreeting(t, get(t, addr, "/"))
out.waitFor(t, "discriminator gave no answer")
out.waitFor(t, "waf gave no answer")
}
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, greet)
@@ -1275,29 +1275,29 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
}
}
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
func TestHealthCheckIsAnsweredWhileWAFIsDown(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, discriminator.HealthPath)
got := get(t, addr, waf.HealthPath)
if got.status != http.StatusOK || got.body != "ok\n" {
t.Errorf("answered %+v, want ok", got)
}
line := out.requestLine(t)
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
if line["path"] != waf.HealthPath || line["disposition"] != nil {
t.Errorf("line %v, want one with no disposition", line)
}
// With discriminator up too, it is not asked.
// With waf up too, it is not asked.
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
get(t, addr, discriminator.HealthPath)
get(t, addr, waf.HealthPath)
if len(standIn.questions) != 0 {
t.Error("discriminator was asked about the health check")
t.Error("waf was asked about the health check")
}
}
@@ -1305,7 +1305,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
t.Parallel()
release := make(chan struct{})
backend := startDiscriminatorAnsweringReports(t, deny,
backend := startWAFAnsweringReports(t, deny,
func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
@@ -1340,16 +1340,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
_ = res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
t.Errorf("status %d, want waf's 403", res.StatusCode)
}
}
}
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
t.Parallel()
release := make(chan struct{})
backend := startDiscriminatorAnsweringReports(t, allow,
backend := startWAFAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
<-release
@@ -1369,10 +1369,10 @@ func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.
out.waitFor(t, "a report is dropped")
}
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
func TestAReportWAFRefusesIsDroppedWithAWarning(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t, allow,
backend := startWAFAnsweringReports(t, allow,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
})
@@ -1387,7 +1387,7 @@ func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
t.Parallel()
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
backend := startWAFAnsweringReports(t, allow, switchAndHold)
app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
@@ -1400,7 +1400,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
@@ -1416,7 +1416,7 @@ func TestReportGivesWhatHappened(t *testing.T) {
const body = "the visitor's body"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -1446,7 +1446,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
if r.Header.Get(waf.ClientHeader) != "" {
allow(w, r)
return
@@ -1501,7 +1501,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, echoAfterUpgrade)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000",
@@ -1556,7 +1556,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
const goodbye = "sent after the visitor stopped sending\n"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
@@ -1614,7 +1614,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
//nolint:paralleltest // see above
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
closed := make(chan error, 1)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
@@ -1685,7 +1685,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
const fromTraefik = "an-id-from-traefik"
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
@@ -1715,7 +1715,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
body = "the visitor's body"
)
standIn := startDiscriminator(t, allow)
standIn := startWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
@@ -11,15 +11,15 @@ import (
"net/http"
"strings"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
// answerBodyMaxBytes is the most of the body of waf's answer that is read.
// It is far above any answer's.
const answerBodyMaxBytes = 1 << 20
// reportAnswerMaxBytes is the most of the answer to a report that is read,
// which has no body when discriminator takes the report.
// which has no body when waf takes the report.
const reportAnswerMaxBytes = 4 << 10
var (
@@ -27,22 +27,22 @@ var (
errReportRefused = errors.New("the report was answered")
)
// discriminatorAnswer is what discriminator answered to a question.
type discriminatorAnswer struct {
// wafAnswer is what waf answered to a question.
type wafAnswer struct {
status int
header http.Header
body []byte
}
// noteAnswer notes what discriminator answered. An answer without a valid disposition
// notes nothing, and no report is due for it: discriminator holds no decision to report
// noteAnswer notes what waf answered. An answer without a valid disposition
// notes nothing, and no report is due for it: waf holds no decision to report
// on.
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
disposition := answer.header.Get(discriminator.DispositionHeader)
func (rq *request) noteAnswer(answer *wafAnswer) {
disposition := answer.header.Get(waf.DispositionHeader)
switch disposition {
case discriminator.DispositionAllow, discriminator.DispositionDeny,
discriminator.DispositionClose:
case waf.DispositionAllow, waf.DispositionDeny,
waf.DispositionClose:
rq.disposition = disposition
rq.answered = true
rq.asn = answer.header.Get(asnHeader)
@@ -55,27 +55,27 @@ func (rq *request) noteAnswer(answer *discriminatorAnswer) {
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
switch {
case ctx.Err() != nil:
// The visitor went away while discriminator was being asked. discriminator has not
// The visitor went away while waf was being asked. waf has not
// failed, and nothing is noted. This panic has Go's server close the
// connection without an answer, and log nothing; the deferred finish still
// runs.
panic(http.ErrAbortHandler)
case errors.Is(err, errCannotPassOn):
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
rq.h.processLog.Warn("waf's answer is dropped", "error", err.Error())
rq.answer(http.StatusBadGateway)
default:
// discriminator cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
// waf cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps waf busy past
// SWRP_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever discriminator is slow. Making such requests hard to send is
// whenever waf is slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true
if rq.disposition == discriminator.DispositionAllow {
if rq.disposition == waf.DispositionAllow {
rq.passToApp(ctx)
} else {
rq.answer(http.StatusServiceUnavailable)
@@ -83,10 +83,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
}
}
// ask sends discriminator the question, and returns its answer, which must have come
// ask sends waf the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away.
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
defer cancel()
@@ -95,7 +95,7 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
return nil, err
}
res, err := rq.h.discriminatorTransport.RoundTrip(question)
res, err := rq.h.wafTransport.RoundTrip(question)
if err != nil {
return nil, fmt.Errorf("send the question: %w", err)
}
@@ -120,17 +120,17 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
}
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
return &wafAnswer{res.StatusCode, res.Header, body}, nil
}
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
// canBeAsked reports whether Go's client can send waf r as the visitor sent
// it. It cannot send these as they came:
// - a target that does not start with /, or that starts with //;
// - an empty Host, for which it sends discriminator's own address;
// - an empty Host, for which it sends waf's own address;
// - a Host holding an IPv6 zone, which it removes;
// - more than one User-Agent, of which it sends the first;
// - an empty User-Agent, which it leaves out, the one case that still goes to
// discriminator.
// waf.
func canBeAsked(r *http.Request) bool {
target, host := r.RequestURI, r.Host
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
@@ -170,13 +170,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
question.TransferEncoding = in.TransferEncoding
question.Header = in.Header.Clone()
question.Header.Set(discriminator.ClientHeader, rq.client.String())
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
question.Header.Del(discriminator.ContentLengthHeader)
question.Header.Set(waf.ClientHeader, rq.client.String())
question.Header.Set(waf.PeerHeader, rq.peer.String())
question.Header.Del(waf.ContentLengthHeader)
announced := in.Header.Get("Content-Length")
if announced != "" {
question.Header.Set(discriminator.ContentLengthHeader, announced)
question.Header.Set(waf.ContentLengthHeader, announced)
}
question.Header.Set(requestIDHeader, rq.id)
@@ -191,13 +191,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
return question, nil
}
// answerWith gives the visitor discriminator's answer, less its disposition.
func (rq *request) answerWith(answer *discriminatorAnswer) {
// answerWith gives the visitor waf's answer, less its disposition.
func (rq *request) answerWith(answer *wafAnswer) {
rq.prepareOwnAnswer(rq.requestDeadline())
header := rq.out.Header()
maps.Copy(header, answer.header)
header.Del(discriminator.DispositionHeader)
header.Del(waf.DispositionHeader)
// Go's server adds a Content-Type it guesses from the body to a response whose
// headers have no Content-Type key. The key with no value sends none.
@@ -208,10 +208,10 @@ func (rq *request) answerWith(answer *discriminatorAnswer) {
rq.send(answer.status, answer.body)
}
// sendReport sends discriminator report on a goroutine of its own, so that the
// sendReport sends waf report on a goroutine of its own, so that the
// visitor does not wait for it. The report goes on after ctx, the request's own
// context, has ended.
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
h.reportsMu.Lock()
defer h.reportsMu.Unlock()
@@ -227,9 +227,9 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report)
})
}
// postReport posts report to discriminator, and waits for its answer for up to
// postReport posts report to waf, and waits for its answer for up to
// SWRP_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode the report: %w", err)
@@ -246,7 +246,7 @@ func (h *handler) postReport(ctx context.Context, report *discriminator.Report)
req.Header.Set("Content-Type", "application/json")
res, err := h.discriminatorTransport.RoundTrip(req)
res, err := h.wafTransport.RoundTrip(req)
if err != nil {
return fmt.Errorf("send the report: %w", err)
}
+10 -10
View File
@@ -11,7 +11,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// healthCheckTimeout bounds the whole health check.
@@ -19,8 +19,8 @@ const healthCheckTimeout = 5 * time.Second
var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In discriminator's container, where
// SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
// HealthCheck is the container's health check. In waf's container, where
// SWW_COMMAND is waf, it returns 0 while waf answers its health
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
@@ -49,8 +49,8 @@ func HealthCheck(
switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "discriminator":
err = checkDiscriminator(ctx, lookupEnv)
case command == "waf":
err = checkWAF(ctx, lookupEnv)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
}
@@ -64,18 +64,18 @@ func HealthCheck(
return 0
}
// checkDiscriminator asks discriminator's health endpoint.
func checkDiscriminator(
// checkWAF asks waf's health endpoint.
func checkWAF(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, err := config.DiscriminatorListenAddr(lookupEnv)
listenAddr, err := config.WAFListenAddr(lookupEnv)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
err = askHealthEndpoint(ctx, listenAddr)
if err != nil {
return fmt.Errorf("ask discriminator: %w", err)
return fmt.Errorf("ask waf: %w", err)
}
return nil
@@ -109,7 +109,7 @@ func checkRProxyAndApp(
// listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + discriminator.HealthPath
health := "http://" + net.JoinHostPort("127.0.0.1", port) + waf.HealthPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
if err != nil {
+11 -11
View File
@@ -19,11 +19,11 @@ const (
// commandSetting says which command a container runs, and which the health
// check checks.
commandSetting = "SWW_COMMAND"
// discriminatorCommand is the command that runs discriminator.
discriminatorCommand = "discriminator"
// wafCommand is the command that runs waf.
wafCommand = "waf"
)
func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
func TestHealthCheckInWAFsContainer(t *testing.T) {
t.Parallel()
ctx, stop := context.WithCancel(t.Context())
@@ -47,7 +47,7 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
// The container's settings: an address to listen on with an empty
// host part, which the health check asks at 127.0.0.1.
wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand, listenAddr: ":" + port,
commandSetting: wafCommand, listenAddr: ":" + port,
}, 0, "")
// The health check reads that setting alone, here given as a file: a removed
@@ -59,7 +59,7 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
{"SWW_MODE", "neither"},
} {
wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand,
commandSetting: wafCommand,
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
other.name: other.value,
}, 0, "")
@@ -68,10 +68,10 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
// Listening on port 0 takes any free port, so nothing listens on port 0
// itself.
wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand, listenAddr: ":0",
}, 1, "unhealthy: ask discriminator: ")
commandSetting: wafCommand, listenAddr: ":0",
}, 1, "unhealthy: ask waf: ")
wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand, listenAddr: "8080",
commandSetting: wafCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
stop()
@@ -90,7 +90,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
defer app.Close()
// Nothing listens at the backend. The health check asks rproxy and the app,
// whether or not discriminator answers.
// whether or not waf answers.
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
settings := func(more ...string) map[string]string {
@@ -107,7 +107,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
// SWW_COMMAND cannot be given as a file.
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "discriminator\n")),
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "waf\n")),
0, "")
// Another setting does not fail it, even an invalid one, and neither does
@@ -137,7 +137,7 @@ func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
"unhealthy: invalid setting: SWW_COMMAND: "+
"\"healthcheck\" is not rproxy or discriminator\n")
"\"healthcheck\" is not rproxy or waf\n")
}
func TestHealthCheckRefusesAnArgument(t *testing.T) {
+53 -53
View File
@@ -19,18 +19,18 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
// wafBackend is the setting that names discriminator to rproxy.
// wafBackend is the setting that names waf to rproxy.
wafBackend = "SWRP_WAF_BACKEND"
// rproxyListenAddr is the setting that says where rproxy listens.
rproxyListenAddr = "SWRP_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for discriminator.
// rproxyTimeout is the setting that says how long rproxy waits for waf.
rproxyTimeout = "SWRP_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env"
@@ -52,7 +52,7 @@ func runRProxy(ctx context.Context, env map[string]string, out *output) int {
})
}
// startRProxy runs rproxy with discriminator at backend and the app at appURL, and
// startRProxy runs rproxy with waf at backend and the app at appURL, and
// returns its URL and what it writes. It believes X-Forwarded-For from the
// tests. It stops as the test ends, and that it stopped in order is checked.
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
@@ -91,10 +91,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
return "http://" + addr, out
}
// startDiscriminator runs discriminator with the settings in env, and returns
// startWAF runs waf with the settings in env, and returns
// its address and URL, what it writes, and what stops it, which checks that it
// stopped in order.
func startDiscriminator(t *testing.T, env map[string]string) (string, *output, func()) {
func startWAF(t *testing.T, env map[string]string) (string, *output, func()) {
t.Helper()
ctx, stop := context.WithCancel(t.Context())
@@ -113,17 +113,17 @@ func startDiscriminator(t *testing.T, env map[string]string) (string, *output, f
select {
case status := <-exited:
if status != 0 {
t.Fatalf("discriminator's exit status %d, want 0; output:\n%s", status, out.text())
t.Fatalf("waf's exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Fatal("discriminator still running after being told to stop")
t.Fatal("waf still running after being told to stop")
}
}
}
// discriminatorSettings are discriminator's settings with the default rule
// wafSettings are waf's settings with the default rule
// file, to listen at any free port.
func discriminatorSettings(t *testing.T) map[string]string {
func wafSettings(t *testing.T) map[string]string {
t.Helper()
return map[string]string{
@@ -136,7 +136,7 @@ func discriminatorSettings(t *testing.T) map[string]string {
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
t.Parallel()
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
addr, wafOut, stop := startWAF(t, wafSettings(t))
defer stop()
backend := "http://" + addr
@@ -151,32 +151,32 @@ func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
wantStatus(t, second+"/", placed, http.StatusForbidden)
wantStatus(t, second+"/", otherClient, http.StatusOK)
// One request's three lines carry one id, rproxy's with discriminator's answer.
// One request's three lines carry one id, rproxy's with waf's answer.
line := firstOut.line(t, "path", bannedProbe)
if line["disposition"] != discriminator.DispositionDeny ||
if line["disposition"] != waf.DispositionDeny ||
line["client_ip"] != placed {
t.Errorf("rproxy's line is %v", line)
}
id, _ := line["request_id"].(string)
decision := discriminatorLine(t, discriminatorOut, id, "decision")
report := discriminatorLine(t, discriminatorOut, id, "report")
decision := wafLine(t, wafOut, id, "decision")
report := wafLine(t, wafOut, id, "report")
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
report["status"] != float64(http.StatusForbidden) {
t.Errorf("discriminator's lines are %v and %v", decision, report)
t.Errorf("waf's lines are %v and %v", decision, report)
}
}
// discriminatorLine waits for discriminator's line with request id and
// discriminator_line kind.
func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]any {
// wafLine waits for waf's line with request id and
// waf_line kind.
func wafLine(t *testing.T, out *output, id, kind string) map[string]any {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
line := out.findLine(t, func(fields map[string]any) bool {
return fields["request_id"] == id && fields["discriminator_line"] == kind
return fields["request_id"] == id && fields["waf_line"] == kind
})
if line != nil {
return line
@@ -190,14 +190,14 @@ func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]an
return nil
}
func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T) {
func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
t.Parallel()
env := discriminatorSettings(t)
addr, _, stop := startDiscriminator(t, env)
env := wafSettings(t)
addr, _, stop := startWAF(t, env)
// rproxy asks discriminator through an address the test holds throughout,
// so no other test can take it while discriminator is stopped.
// rproxy asks waf through an address the test holds throughout,
// so no other test can take it while waf is stopped.
backend, forwardTo := startForwarder(t)
forwardTo(addr)
@@ -228,22 +228,22 @@ func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T)
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
wantStatus(t, site+"/", placed, http.StatusForbidden)
// rproxy's connections are closed at once from here, before discriminator
// rproxy's connections are closed at once from here, before waf
// stops and frees its port.
forwardTo("")
stop()
// With discriminator down, the site answers, and rproxy says the default did it.
// With waf down, the site answers, and rproxy says the default did it.
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop")
if line["disposition"] != discriminator.DispositionAllow ||
if line["disposition"] != waf.DispositionAllow ||
line["default_disposition"] != true {
t.Errorf("rproxy's line is %v", line)
}
// Started again with its state files, it applies the ban again.
addr, _, stop = startDiscriminator(t, env)
addr, _, stop = startWAF(t, env)
forwardTo(addr)
defer func() {
@@ -397,10 +397,10 @@ func sendRaw(t *testing.T, site, request string) int {
return res.StatusCode
}
func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
func TestAChunkedBodyStaysABodyAtWAF(t *testing.T) {
t.Parallel()
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
addr, wafOut, stop := startWAF(t, wafSettings(t))
defer stop()
site, _ := startRProxy(t, "http://"+addr, startApp(t))
@@ -409,7 +409,7 @@ func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
const smuggledID = "smuggled-request"
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
discriminator.ClientHeader + ": " + otherClient + "\r\n" +
waf.ClientHeader + ": " + otherClient + "\r\n" +
"X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
@@ -425,18 +425,18 @@ func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
time.Sleep(pollInterval)
}
line := discriminatorOut.findLine(t, func(fields map[string]any) bool {
line := wafOut.findLine(t, func(fields map[string]any) bool {
return fields["request_id"] == smuggledID
})
if line != nil {
t.Errorf("discriminator read the body as a request: %v", line)
t.Errorf("waf read the body as a request: %v", line)
}
}
func TestATargetThatCannotGoToDiscriminatorIsRefusedForABannedClient(t *testing.T) {
func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
t.Parallel()
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
addr, _, stop := startWAF(t, wafSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t))
@@ -460,7 +460,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
const zoned = "2001:db8::1%eth0"
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
addr, _, stop := startWAF(t, wafSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t))
@@ -475,7 +475,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
}
}
func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) {
func TestAStatusWAFDoesNotTakeIsReportedAs599(t *testing.T) {
t.Parallel()
const status = 799
@@ -486,7 +486,7 @@ func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) {
}))
t.Cleanup(app.Close)
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
addr, wafOut, stop := startWAF(t, wafSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, app.URL)
@@ -500,9 +500,9 @@ func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) {
id, _ := line["request_id"].(string)
report := discriminatorLine(t, discriminatorOut, id, "report")
report := wafLine(t, wafOut, id, "report")
if report["status"] != 599.0 {
t.Errorf("discriminator's line is %v, want status 599", report)
t.Errorf("waf's line is %v, want status 599", report)
}
}
@@ -544,16 +544,16 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output
return addr, cmd, out
}
func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Parallel()
protocols := make(chan string, 10)
backend := httptest.NewUnstartedServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
if r.Header.Get(waf.ClientHeader) != "" {
protocols <- r.Proto
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionDeny)
w.Header().Set(waf.DispositionHeader, waf.DispositionDeny)
w.WriteHeader(http.StatusForbidden)
}
}))
@@ -563,7 +563,7 @@ func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
// rproxy has no setting for the certificates it trusts, so it is told of this
// one as Go's client is told of any, in a process of its own.
certificates := filepath.Join(t.TempDir(), "discriminator.pem")
certificates := filepath.Join(t.TempDir(), "waf.pem")
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
@@ -585,7 +585,7 @@ func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
} {
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
if status != http.StatusForbidden {
t.Errorf("with %s: status %d, want discriminator's 403", name, status)
t.Errorf("with %s: status %d, want waf's 403", name, status)
}
select {
@@ -594,7 +594,7 @@ func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
}
case <-time.After(waitLimit):
t.Errorf("with %s: discriminator was not asked", name)
t.Errorf("with %s: waf was not asked", name)
}
}
}
@@ -623,8 +623,8 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(discriminator.ClientHeader) != "" {
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
if r.Header.Get(waf.ClientHeader) != "" {
w.Header().Set(waf.DispositionHeader, waf.DispositionAllow)
return
}
@@ -731,7 +731,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
w.Header().Set(waf.DispositionHeader, waf.DispositionAllow)
}))
t.Cleanup(backend.Close)
@@ -794,7 +794,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
{"no backend", wafBackend, nil},
{
"another invalid", rproxyTimeout,
map[string]string{wafBackend: "http://discriminator:8080", rproxyTimeout: "soon"},
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -831,7 +831,7 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
out := &output{}
status := runRProxy(t.Context(), map[string]string{
wafBackend: "http://discriminator:8080",
wafBackend: "http://waf:8080",
rproxyListenAddr: taken.Addr().String(),
}, out)
if status != 1 {
@@ -848,7 +848,7 @@ func TestMainRunsRProxy(t *testing.T) {
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+discriminator.HealthPath, http.NoBody)
"http://"+addr+waf.HealthPath, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
+18 -18
View File
@@ -1,9 +1,9 @@
// Package sww runs the sww process, as the command it is given.
// `sww discriminator` reads the settings, the rule files, the lookup database
// `sww waf` reads the settings, the rule files, the lookup database
// and the state files, answers rproxy's questions until it is told to stop, and
// then stops in an orderly way, writing the state files. `sww rproxy`
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
// that asks discriminator about each request. `sww healthcheck` is the
// that asks waf about each request. `sww healthcheck` is the
// container's health check.
package sww
@@ -23,13 +23,13 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// shutdownTimeout is how long requests in progress may take to finish
@@ -37,7 +37,7 @@ import (
// runit and docker wait a little longer before they kill the process.
const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as discriminator stops, the log lines still
// remoteLogStopTimeout is how long, as waf stops, the log lines still
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
// carried them.
const remoteLogStopTimeout = 2 * time.Second
@@ -51,11 +51,11 @@ const usage = `usage: sww <command>
commands:
rproxy the reverse proxy in front of one app
discriminator decides for rproxy, and keeps bans, counters and history
waf decides for rproxy, and keeps bans, counters and history
healthcheck the container's health check
`
// Params are what RunDiscriminator and RunRProxy need from the process.
// Params are what RunWAF and RunRProxy need from the process.
type Params struct {
// Version is the version of the binary, set when it is built.
Version string
@@ -66,7 +66,7 @@ type Params struct {
}
// Main runs the command os.Args names, and returns the process's exit status.
// `sww rproxy` and `sww discriminator` run until SIGTERM or SIGINT.
// `sww rproxy` and `sww waf` run until SIGTERM or SIGINT.
// With no command, or one it does not know, it prints the commands and returns
// usageStatus.
func Main(version string) int {
@@ -84,8 +84,8 @@ func Main(version string) int {
switch args[0] {
case "rproxy":
return RunRProxy(ctx, params)
case "discriminator":
return RunDiscriminator(ctx, params)
case "waf":
return RunWAF(ctx, params)
case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
default:
@@ -100,10 +100,10 @@ func printUsage(w io.Writer) int {
return usageStatus
}
// RunDiscriminator is `sww discriminator`: it reads the settings, the rule files,
// RunWAF is `sww waf`: it reads the settings, the rule files,
// the lookup database and the state files, then serves requests until ctx is
// done. It returns the process's exit status, 1 when discriminator cannot start.
func RunDiscriminator(ctx context.Context, params Params) int {
// done. It returns the process's exit status, 1 when waf cannot start.
func RunWAF(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's
// error, which every SWW_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout,
@@ -151,7 +151,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
return 1
}
crs, err := discriminator.NewCoreRuleSet(cfg)
crs, err := waf.NewCoreRuleSet(cfg)
if err != nil {
processLog.Error("cannot use the Core Rule Set", "error", err.Error())
@@ -191,14 +191,14 @@ func RunDiscriminator(ctx context.Context, params Params) int {
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
}
// newServer returns the server discriminator runs, with the metrics of the alerts,
// newServer returns the server waf runs, with the metrics of the alerts,
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error.
func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *coreruleset.CoreRuleSet,
) (*discriminator.Server, error) {
) (*waf.Server, error) {
var lookupFile *lookup.File
if cfg.LookupSource == "file" {
@@ -215,7 +215,7 @@ func newServer(
}
}
server := discriminator.New(discriminator.Params{
server := waf.New(waf.Params{
Config: cfg,
RequestLog: stdout,
ProcessLog: processLog,
@@ -239,7 +239,7 @@ func newServer(
// loadStateFiles reads the state files into the parts of server and into
// alertQueue, as state.Load does.
func loadStateFiles(
cfg *config.Config, server *discriminator.Server, alertQueue *alerts.Queue,
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) {
return state.Load(state.Params{
@@ -325,7 +325,7 @@ func startSending(
// shutdownTimeout to finish, and writes every state file, alerts.json with
// the alerts still waiting.
func serve(
ctx context.Context, server *discriminator.Server, listener net.Listener,
ctx context.Context, server *waf.Server, listener net.Listener,
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
processLog *slog.Logger,
) int {
+9 -9
View File
@@ -20,9 +20,9 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -123,11 +123,11 @@ func (o *output) text() string {
return o.buf.String()
}
// run runs `sww discriminator` with the settings in env until ctx is done,
// run runs `sww waf` with the settings in env until ctx is done,
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int {
return sww.RunDiscriminator(ctx, sww.Params{
return sww.RunWAF(ctx, sww.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
@@ -1245,7 +1245,7 @@ func startApp(t *testing.T) string {
return app.URL
}
// runUntilStopped runs `sww discriminator` with the settings in env, has use
// runUntilStopped runs `sww waf` with the settings in env, has use
// send it requests at url, then stops it as SIGTERM does, checks that it
// stopped in order, and returns its output.
func runUntilStopped(
@@ -1319,7 +1319,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
}
}
// wantAllowed checks that discriminator at url allows a request from localhost.
// wantAllowed checks that waf at url allows a request from localhost.
func wantAllowed(t *testing.T, url string) {
t.Helper()
@@ -1504,7 +1504,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
return file.Bans[0]
}
// wantRefused checks that discriminator at url refuses a request from localhost with
// wantRefused checks that waf at url refuses a request from localhost with
// 403, the default SWW_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) {
t.Helper()
@@ -1681,8 +1681,8 @@ func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost
}
// statusFrom returns the status a request to url from the client at from is
// answered with. The request serves rproxy and discriminator alike. rproxy reads the
// client from X-Forwarded-For, and replaces the other two headers. To discriminator,
// answered with. The request serves rproxy and waf alike. rproxy reads the
// client from X-Forwarded-For, and replaces the other two headers. To waf,
// they make it a question about that client.
func statusFrom(t *testing.T, url, from string) int {
t.Helper()
@@ -1694,7 +1694,7 @@ func statusFrom(t *testing.T, url, from string) int {
}
req.Header.Set("X-Forwarded-For", from)
req.Header.Set(discriminator.ClientHeader, from)
req.Header.Set(waf.ClientHeader, from)
req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{}
@@ -14,8 +14,8 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// commandForMain, while set in the environment, has this test binary run Main
@@ -34,7 +34,7 @@ func TestMain(m *testing.M) {
os.Exit(sww.Main(testVersion))
}
func TestMainRunsDiscriminator(t *testing.T) {
func TestMainRunsWAF(t *testing.T) {
t.Parallel()
binary, err := os.Executable()
@@ -50,7 +50,7 @@ func TestMainRunsDiscriminator(t *testing.T) {
cmd.Stdout = out
cmd.Env = append(os.Environ(),
commandForMain+"="+discriminatorCommand,
commandForMain+"="+wafCommand,
listenAddr+"="+localhost+":0",
stateDir+"="+t.TempDir(),
rulesDir+"="+t.TempDir(),
@@ -67,7 +67,7 @@ func TestMainRunsDiscriminator(t *testing.T) {
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow)
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil {
@@ -108,7 +108,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
t.Errorf("exit status %d (%v), want 2", code, err)
}
for _, name := range []string{"rproxy", discriminatorCommand, "healthcheck"} {
for _, name := range []string{"rproxy", wafCommand, "healthcheck"} {
if !strings.Contains(stderr.String(), name) {
t.Errorf("stderr %q does not name %s", stderr.String(), name)
}
@@ -121,7 +121,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
}
}
// ask sends discriminator at addr rproxy's question about a request for path from
// ask sends waf at addr rproxy's question about a request for path from
// placed, checks the answer's status and disposition, and closes it.
func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Helper()
@@ -132,7 +132,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Fatalf("new request: %v", err)
}
req.Header.Set(discriminator.ClientHeader, placed)
req.Header.Set(waf.ClientHeader, placed)
req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{}
@@ -147,16 +147,16 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
_ = res.Body.Close()
if res.StatusCode != status ||
res.Header.Get(discriminator.DispositionHeader) != disposition {
res.Header.Get(waf.DispositionHeader) != disposition {
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
res.Header.Get(discriminator.DispositionHeader), status, disposition)
res.Header.Get(waf.DispositionHeader), status, disposition)
}
}
// runDiscriminator runs `sww discriminator` with the settings in env, has use
// runWAF runs `sww waf` with the settings in env, has use
// send it requests at the address it listens on, stops it as SIGTERM does, and
// checks that it stopped in order.
func runDiscriminator(t *testing.T, env map[string]string, use func(addr string)) {
func runWAF(t *testing.T, env map[string]string, use func(addr string)) {
t.Helper()
ctx, stop := context.WithCancel(t.Context())
@@ -183,7 +183,7 @@ func runDiscriminator(t *testing.T, env map[string]string, use func(addr string)
out.line(t, "msg", "stopped")
}
func TestDiscriminatorKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
t.Parallel()
env := map[string]string{
@@ -192,13 +192,13 @@ func TestDiscriminatorKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
}
runDiscriminator(t, env, func(addr string) {
ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow)
runWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
// The default rule file bans a probe for this.
ask(t, addr, "/.env", http.StatusForbidden, discriminator.DispositionDeny)
ask(t, addr, "/.env", http.StatusForbidden, waf.DispositionDeny)
})
runDiscriminator(t, env, func(addr string) {
ask(t, addr, "/", http.StatusForbidden, discriminator.DispositionDeny)
runWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusForbidden, waf.DispositionDeny)
})
}
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"bytes"
@@ -49,7 +49,7 @@ func (rq *request) answerHealth() {
_, _ = io.WriteString(rq.out, "ok\n")
}
// answerAdmin answers a request for one of discriminator's own endpoints. Each
// answerAdmin answers a request for one of waf's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other
@@ -250,7 +250,7 @@ type clientAnswer struct {
Bans []state.BanEntry `json:"bans"`
}
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
// showClient answers GET ClientsPath<ip> with what waf knows of the client:
// its counters, its history, which holds its country as last looked up and its
// offences, and its bans with their notes.
func (rq *request) showClient() {
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"encoding/json"
@@ -10,10 +10,10 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -88,11 +88,11 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
// Any other request under /_sww/ is not found.
for _, e := range []adminEndpoint{
{http.MethodPut, discriminator.BansPath, banOtherClient},
{http.MethodDelete, discriminator.BansPath, ""},
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
{http.MethodPut, waf.BansPath, banOtherClient},
{http.MethodDelete, waf.BansPath, ""},
{http.MethodGet, waf.BansPath + "/" + otherClient, ""},
{http.MethodPost, waf.ClientsPath + otherClient, ""},
{http.MethodGet, strings.TrimSuffix(waf.ClientsPath, "/"), ""},
} {
s.admin(e.method, e.path, e.body, http.StatusNotFound)
}
@@ -118,7 +118,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
Reason: "probes for logins",
}
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient,
http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
// Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted.
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
lifted := clk.Now()
want.Lifted = &lifted
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "",
http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward)
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
// No ban on it is active any more.
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusNotFound)
}
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -181,7 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires
}
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
wantBans(t, s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
want)
}
}
@@ -251,7 +251,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
"request body too large",
},
} {
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
got := s.admin(http.MethodPost, waf.BansPath, tc.body,
http.StatusBadRequest)
if !strings.Contains(string(got.body), tc.want) {
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
@@ -285,7 +285,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
Bans []state.BanEntry `json:"bans"`
}
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
decode(t, s.admin(http.MethodGet, waf.ClientsPath+addr, "",
http.StatusOK), &got)
if got.Client == nil {
@@ -308,15 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// Of an address no request came from and no ban covers, nothing is
// known.
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "",
http.StatusOK)
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body)
}
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
s.admin(http.MethodGet, waf.ClientsPath+"203.0.113", "",
http.StatusBadRequest)
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
s.admin(http.MethodDelete, waf.BansPath+"/203.0.113.0/24", "",
http.StatusBadRequest)
}
@@ -347,14 +347,14 @@ type adminEndpoint struct {
// bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint {
return []adminEndpoint{
{http.MethodGet, discriminator.BansPath, ""},
{http.MethodPost, discriminator.BansPath, banOtherClient},
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
{http.MethodGet, waf.BansPath, ""},
{http.MethodPost, waf.BansPath, banOtherClient},
{http.MethodDelete, waf.BansPath + "/" + otherClient, ""},
{http.MethodGet, waf.ClientsPath + otherClient, ""},
}
}
// admin sends a request with method for path, with body, to discriminator's own
// admin sends a request with method for path, with body, to waf's own
// address, with the admin token, and checks that it is answered with status, its
// log line's action own_endpoint. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer {
@@ -370,7 +370,7 @@ func (s *sender) ownEndpointRequest(
) answer {
s.t.Helper()
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
req := newRequest(s.t, method, s.wafAddr, path, strings.NewReader(body))
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"maps"
@@ -10,8 +10,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -203,7 +203,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// never sent them, and returns the queue they wait in as well.
func startWithAlerts(
t *testing.T, env map[string]string,
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
) (*sender, *clock, *waf.Server, *alerts.Queue) {
t.Helper()
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
@@ -212,7 +212,7 @@ func startWithAlerts(
// startAppWithAlerts is startWithAlerts in front of the app handler.
func startAppWithAlerts(
t *testing.T, handler http.HandlerFunc, env map[string]string,
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
) (*sender, *clock, *waf.Server, *alerts.Queue) {
t.Helper()
app := startApp(t, handler)
@@ -226,7 +226,7 @@ func startAppWithAlerts(
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}
return s, clk, server, queue
}
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"net/netip"
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"maps"
@@ -13,9 +13,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// The anomaly thresholds: the prefix of a scope followed by the end of a
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// kept.
geojsURL, _, release := startHeldGeoJS(t)
var server atomic.Pointer[discriminator.Server]
var server atomic.Pointer[waf.Server]
app := startApp(t, func(http.ResponseWriter, *http.Request) {
release()
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"fmt"
@@ -16,17 +16,17 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// discriminator has to answer.
// waf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond
@@ -35,7 +35,7 @@ const (
headerFieldLimit = 100
// cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie"
// tooLarge is how discriminator refuses a request over the header limits.
// tooLarge is how waf refuses a request over the header limits.
tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that silentGeoJSURL names too.
@@ -58,7 +58,7 @@ func cookies(count int) string {
}
// fillHeaders adds headers to a question until the checks see total header
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
// fields: all but ClientHeader and PeerHeader, which waf removes. Go's
// client sends nothing else but Host, which the limit does not count, once
// User-Agent is set.
func fillHeaders(req *http.Request, total int) {
@@ -72,13 +72,13 @@ func fillHeaders(req *http.Request, total int) {
func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
got := do(t, req)
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
@@ -91,7 +91,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testin
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", nil)
s := startWAF(t, "", nil)
req := s.question(siteA, client, "/")
fillHeaders(req, headerFieldLimit+1)
do(t, req)
@@ -145,7 +145,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/")
tc.make(req)
@@ -153,13 +153,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
line := s.out.requestLine(t)
if tc.refused {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
return
}
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationCoreRuleSetMS == nil {
@@ -180,11 +180,11 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", env)
s := startWAF(t, "", env)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
})
}
}
@@ -192,23 +192,23 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
s := startWAF(t, "", map[string]string{wafMode: block, allowNets: client})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow)
}
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
got := do(t, req)
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
@@ -221,15 +221,15 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
for range 2 {
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
}
wantDisposition(t, s.ask(client, "/"), http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, s.ask(client, "/"), http.StatusOK, waf.DispositionAllow)
}
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
@@ -240,7 +240,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
env map[string]string
path string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
prepare func(t *testing.T, server *waf.Server, now time.Time)
}{
{"SWW_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
{"a ban", nil, "/", banClient},
@@ -262,13 +262,13 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", tc.env)
s := startWAF(t, "", tc.env)
tc.prepare(t, s.server, s.clock.Now())
req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
wantLine(t, s.out.requestLine(t), tooLarge, requestlog.ActionRequestHeaderTooLarge)
})
}
@@ -284,7 +284,7 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
lookuptest.Write(t, path, map[string]lookuptest.Network{
client + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
})
s := startDiscriminator(t, "", map[string]string{
s := startWAF(t, "", map[string]string{
lookupSource: fileSource, lookupDBPath: path,
tagLists: "vpn=" + vpnList,
routeWatchURL: startRouteWatch(t, map[string]standInRoute{client: {prefixDE, 64496}}),
@@ -297,11 +297,11 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
over := s.question(siteA, client, "/")
over.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, over), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, do(t, over), tooLarge, waf.DispositionDeny)
within := s.question(siteA, client, "/")
within.Header.Set(cookieHeader, cookies(cookieLimit))
wantDisposition(t, do(t, within), http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, within), http.StatusOK, waf.DispositionAllow)
lines := s.out.requestLines(t, 2)
@@ -332,13 +332,12 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
// The client's next request takes it over the whole limit, so it does
// not wait.
geojsURL, _ := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
})
countClient(t, s.server, s.clock.Now())
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden,
discriminator.DispositionDeny)
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
@@ -354,7 +353,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
// The client's next request takes it over the whole limit, so it does
// not wait.
geojsURL, _ := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", rateLimitPerMinute: "1",
})
s.server.GeoJS.Load([]lookup.Answer{{
@@ -363,8 +362,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
}})
countClient(t, s.server, s.clock.Now())
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden,
discriminator.DispositionDeny)
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, waf.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
@@ -378,14 +376,14 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T)
t.Parallel()
geojsURL, asked := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
s := startWAF(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
got := do(t, req)
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" {
t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+
@@ -393,18 +391,18 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T)
}
}
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
// newWAFInBubble makes a waf for a synctest bubble: GeoJS never
// answers, a setting needs its answer, and everything is at its default but
// for env. No request to it may use the network.
func newDiscriminatorInBubble(
func newWAFInBubble(
t *testing.T, env map[string]string,
) (*discriminator.Server, *output) {
) (*waf.Server, *output) {
t.Helper()
settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
server, out, _ := newWAF(t, silentGeoJSURL, time.Now, settings)
return server, out
}
@@ -412,13 +410,13 @@ func newDiscriminatorInBubble(
// askDirectly hands a question about a request from client straight to server,
// as change leaves it, and returns the answer's status and how long it took.
func askDirectly(
t *testing.T, server *discriminator.Server, change func(*http.Request),
t *testing.T, server *waf.Server, change func(*http.Request),
) (int, time.Duration) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(discriminator.ClientHeader, client)
req.Header.Set(waf.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id")
change(req)
@@ -431,17 +429,17 @@ func askDirectly(
}
// banClient bans client at now.
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
func banClient(_ *testing.T, server *waf.Server, now time.Time) {
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
}
// countClient counts one request from client at now for the rate limits.
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
func countClient(_ *testing.T, server *waf.Server, now time.Time) {
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
}
// listClient puts client on the blocklist at dropURL.
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
func listClient(t *testing.T, server *waf.Server, now time.Time) {
t.Helper()
err := server.Lists.Load([]reputation.List{{
@@ -453,13 +451,13 @@ func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
}
// decideOnClient puts client on the CrowdSec decision list.
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
func decideOnClient(t *testing.T, server *waf.Server, now time.Time) {
t.Helper()
loadDecisions(t, server, now, clientsDecision)
}
// leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
func leaveAsItIs(*testing.T, *waf.Server, time.Time) {}
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Parallel()
@@ -470,7 +468,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
name string
env map[string]string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
prepare func(t *testing.T, server *waf.Server, now time.Time)
// cookie is the request's Cookie header.
cookie string
status int
@@ -517,7 +515,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) {
server, out := newDiscriminatorInBubble(t, tc.env)
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) {
@@ -545,7 +543,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
name string
env map[string]string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
prepare func(t *testing.T, server *waf.Server, now time.Time)
status int
action string
}{
@@ -574,7 +572,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
server, out := newDiscriminatorInBubble(t, tc.env)
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(*http.Request) {})
@@ -590,7 +588,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
}
}
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
t *testing.T,
) {
t.Parallel()
@@ -605,12 +603,12 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
t.Cleanup(func() { _ = resolver.Close() })
server, out, _ := newDiscriminatorWith(t, func(
params discriminator.Params,
) *discriminator.Server {
server, out, _ := newWAFWith(t, func(
params waf.Params,
) *waf.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL
return discriminator.New(params)
return waf.New(params)
}, held, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"bufio"
@@ -15,8 +15,8 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -393,14 +393,14 @@ func (c *clock) advance(d time.Duration) {
c.now = c.now.Add(d)
}
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
// startWithClock starts waf, and rproxy in front of an app that answers 200,
// with the settings in env on top of trusting localhost's
// X-Forwarded-For, clients' AS numbers and countries looked up at
// geojsURL, and a clock set to midnight, the start of a bucket in every
// window.
func startWithClock(
t *testing.T, geojsURL string, env map[string]string,
) (*sender, *clock, *discriminator.Server) {
) (*sender, *clock, *waf.Server) {
t.Helper()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
@@ -410,20 +410,20 @@ func startWithClock(
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
return &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}, clk, server
}
// sender sends requests to rproxy one after another, each on a connection of its
// own, and checks each one's answer and log line. discriminatorAddr is where
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request
// discriminator gets must come through the sender, since the log lines are
// own, and checks each one's answer and log line. wafAddr is where
// waf's own endpoints are, which ownEndpointRequest asks. Every request
// waf gets must come through the sender, since the log lines are
// matched to them in order.
type sender struct {
t *testing.T
addr string
discriminatorAddr string
out *output
sent int
t *testing.T
addr string
wafAddr string
out *output
sent int
}
// get sends a GET request for / from the client at from.
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"sneak.berlin/go/smallwebwaf/internal/config"
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"fmt"
@@ -15,9 +15,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// The biased thresholds.
@@ -291,7 +291,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client
@@ -335,7 +335,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
})
@@ -437,7 +437,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// clock.
func startWithLookups(
t *testing.T, env map[string]string,
) (*sender, *discriminator.Server, *alerts.Queue) {
) (*sender, *waf.Server, *alerts.Queue) {
t.Helper()
s, _, server, queue := startWithLookupsAndClock(t, env)
@@ -452,7 +452,7 @@ func startWithLookups(
// country, and no other address.
func startWithLookupsAndClock(
t *testing.T, env map[string]string,
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
) (*sender, *clock, *waf.Server, *alerts.Queue) {
t.Helper()
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
@@ -482,12 +482,12 @@ func (s *sender) uploadFrom(from string) logLine {
// straight to server's handler, without the network, and returns once it is
// answered.
func serveFromDE(
t *testing.T, server *discriminator.Server, method string, body io.Reader,
t *testing.T, server *waf.Server, method string, body io.Reader,
) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
req.Header.Set(discriminator.ClientHeader, fromDE)
req.Header.Set(waf.ClientHeader, fromDE)
req.Header.Set(requestIDHeader, "the-id")
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"bufio"
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"crypto/rand"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
// and SWW_WAF_ANOMALY_THRESHOLD.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"io"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"encoding/json"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -9,9 +9,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// The CrowdSec settings, and the tests' engine, which is never asked: each
@@ -184,7 +184,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
// decisionsURL, answer, the engine's answer, fetched at fetched, as
// reputation.json would at start.
func loadDecisions(
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
t *testing.T, server *waf.Server, fetched time.Time, answer string,
) {
t.Helper()
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic deny_nets. It reads SWW_DENY_NETS.
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic header_limits. It reads no setting: its limits are fixed.
@@ -1,11 +1,11 @@
package discriminator_test
package waf_test
import (
"net/http"
"testing"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
)
for range healthChecks {
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
got := s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "",
http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"context"
@@ -9,7 +9,7 @@ import (
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what discriminator does with a
// heuristic is one of the checks that decide what waf does with a
// request.
type heuristic interface {
// name is the heuristic's name, which the log line gives.
@@ -72,7 +72,7 @@ func afterLookup() []heuristic {
}
}
// afterAnswer are the heuristics that run on a request once discriminator has
// afterAnswer are the heuristics that run on a request once waf has
// answered it, before its decision line is written. They ban the client, and do
// not change what the request got: their verdict's action is not used.
func afterAnswer() []heuristic {
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"io"
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "", http.StatusOK)
s.ownEndpointRequest("", http.MethodGet, waf.MetricsPath, "",
http.StatusUnauthorized)
s.scrape()
@@ -128,7 +128,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
// historyOf returns the history of the client at addr.
func historyOf(
t *testing.T, server *discriminator.Server, addr string,
t *testing.T, server *waf.Server, addr string,
) ratelimit.History {
t.Helper()
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"context"
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// clock of the test's own: the wait lasts exactly as long as it should,
// however slowly the test process runs. Nothing in it may wait on the
// network, which would keep that clock from moving on: the request is
// handed to discriminator's handler, and GeoJS is one that never answers.
// handed to waf's handler, and GeoJS is one that never answers.
synctest.Test(t, func(t *testing.T) {
// Not the default. The exclusive list needs the answer, and the app
// is never reached.
const timeout = 3 * time.Second
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
map[string]string{
lookupTimeout: timeout.String(),
allowedCountries: "DE",
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"io"
@@ -11,9 +11,9 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} {
s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
s.ownEndpointRequest(authorization, http.MethodGet, waf.MetricsPath, "",
http.StatusNotFound)
}
}
@@ -46,19 +46,19 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
method, path, authorization string
status int
}{
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
{http.MethodGet, waf.MetricsPath, "", http.StatusUnauthorized},
{
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
http.MethodGet, waf.MetricsPath, "Bearer " + strings.ToUpper(token),
http.StatusUnauthorized,
},
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
{http.MethodGet, waf.MetricsPath, "Basic " + token,
http.StatusUnauthorized},
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
{http.MethodGet, waf.MetricsPath, bearer, http.StatusOK},
{http.MethodGet, waf.MetricsPath, "bearer " + token, http.StatusOK},
{http.MethodPost, waf.MetricsPath, bearer, http.StatusNotFound},
{http.MethodGet, waf.MetricsPath + "/", bearer, http.StatusNotFound},
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
{http.MethodPost, waf.HealthPath, "", http.StatusNotFound},
} {
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
@@ -181,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
wantMetric(t, s.scrape(), admins, 1)
// And a ban made through the endpoint.
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
s.admin(http.MethodPost, waf.BansPath, banOtherClient, http.StatusOK)
wantMetric(t, s.scrape(), admins, 2)
}
@@ -361,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer {
}
}
// scrape asks discriminator at addr for the metrics, with the token, and returns
// scrape asks waf at addr for the metrics, with the token, and returns
// them.
func scrape(t *testing.T, addr string) string {
t.Helper()
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
req := newRequest(t, http.MethodGet, addr, waf.MetricsPath, http.NoBody)
req.Header.Set("Authorization", bearer)
got := do(t, req)
@@ -381,7 +381,7 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string {
s.t.Helper()
return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
return string(s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath,
"", http.StatusOK).body)
}
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,7 +1,7 @@
// Package discriminator is the server the discriminator command runs. It answers
// Package waf is the server the waf command runs. It answers
// rproxy's questions, takes rproxy's reports and answers its own endpoints, and
// writes a log line for each.
package discriminator
package waf
import (
"fmt"
@@ -25,11 +25,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
// ownEndpointPrefix starts the path of each of waf's own endpoints. The app
// never sees a path under it.
const ownEndpointPrefix = "/_sww/"
// HealthPath is the health endpoint of discriminator and of rproxy, which the
// HealthPath is the health endpoint of waf and of rproxy, which the
// container's health check asks.
const HealthPath = "/_sww/healthz"
@@ -41,7 +41,7 @@ const MetricsPath = "/_sww/metrics"
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what discriminator knows of a client, by
// ClientsPath is where an admin asks what waf knows of a client, by
// the client's address after it, with SWW_ADMIN_TOKEN.
const ClientsPath = "/_sww/clients/"
@@ -49,7 +49,7 @@ const ClientsPath = "/_sww/clients/"
type Params struct {
Config *config.Config
// RequestLog receives two JSON lines per question, one at its decision and
// one when its report comes, and one per request to discriminator's own
// one when its report comes, and one per request to waf's own
// endpoints.
RequestLog io.Writer
// ProcessLog receives the process's own messages.
@@ -73,7 +73,7 @@ type Params struct {
// Rules are the rule files' rules, which each request is checked
// against.
Rules *rules.Files
// Alerts receive the alert for each ban discriminator makes or makes
// Alerts receive the alert for each ban waf makes or makes
// permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
@@ -83,7 +83,7 @@ type Params struct {
Alerts *alerts.Queue
}
// Server is the server discriminator runs, with the parts whose state the state
// Server is the server waf runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
@@ -248,7 +248,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
return coreRuleSet, nil
}
// handler is discriminator's handler. It holds what every request shares; what
// handler is waf's handler. It holds what every request shares; what
// belongs to one request is in a request.
type handler struct {
config *config.Config
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"bufio"
@@ -17,11 +17,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -177,9 +177,9 @@ func (o *output) requestLine(t *testing.T) logLine {
return o.requestLines(t, 1)[0]
}
// endedLines waits for count requests that went through rproxy, or to discriminator's
// endedLines waits for count requests that went through rproxy, or to waf's
// own endpoints, to have ended, and returns the last log line of each, in the
// order discriminator took them. A question's last line is the one its report writes.
// order waf took them. A question's last line is the one its report writes.
func (o *output) endedLines(t *testing.T, count int) []logLine {
t.Helper()
@@ -189,7 +189,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
ended := len(found) >= count
for _, line := range found[:min(count, len(found))] {
if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
if line.WAFLine == requestlog.WAFLineDecision {
ended = false
}
}
@@ -207,7 +207,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
}
// lastLines returns the last request log line written so far of each request,
// in the order discriminator took them.
// in the order waf took them.
func (o *output) lastLines(t *testing.T) []logLine {
t.Helper()
@@ -273,9 +273,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
return server
}
// startProxy starts discriminator, and rproxy in front of the app at appURL, each with
// startProxy starts waf, and rproxy in front of the app at appURL, each with
// the settings in env on top of the defaults, and returns where rproxy listens
// and what discriminator writes.
// and what waf writes.
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
t.Helper()
@@ -295,14 +295,14 @@ func startProxyWithGeoJS(
}
// startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns discriminator's server as well, whose
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
// bans made by the time now tells, and returns waf's server as well, whose
// Addr is where waf listens. Unless env sets SWW_RULES_DIR, it is an empty
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
// the label instance of every metric.
func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string,
) (string, *output, *discriminator.Server) {
) (string, *output, *waf.Server) {
t.Helper()
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
@@ -311,14 +311,14 @@ func startProxyWithClock(
}
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
// the alerts discriminator raises as well, as newDiscriminator makes them.
// the alerts waf raises as well, as newWAF makes them.
func startProxyWithAlerts(
t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string,
) (string, *output, *discriminator.Server, *alerts.Queue) {
) (string, *output, *waf.Server, *alerts.Queue) {
t.Helper()
server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
server, out, alertQueue := newWAF(t, geojsURL, now, env)
serve(t, server)
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
@@ -326,7 +326,7 @@ func startProxyWithAlerts(
// serve serves server on a port of its own until the test ends, and sets its
// Addr to where it listens.
func serve(t *testing.T, server *discriminator.Server) {
func serve(t *testing.T, server *waf.Server) {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
@@ -345,17 +345,17 @@ func serve(t *testing.T, server *discriminator.Server) {
})
}
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
// startRProxy starts rproxy in front of the app at appURL, asking the waf
// at wafAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
func startRProxy(
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
t *testing.T, wafAddr, appURL string, env map[string]string,
) string {
t.Helper()
settings := map[string]string{
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
"SWRP_WAF_BACKEND": "http://" + wafAddr,
"SWRP_UPSTREAM_URL": appURL,
"SWRP_TIMEOUT_MS": "60000",
}
@@ -392,7 +392,7 @@ func startRProxy(
return listener.Addr().String()
}
// newDiscriminator makes discriminator's server, without starting it, and
// newWAF makes waf's server, without starting it, and
// returns it, what it writes, and the queue of the alerts it raises, as the
// settings in env make it. No alert is sent from the queue: they wait in it,
// for the test to look at.
@@ -402,19 +402,19 @@ func startRProxy(
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
// env sets it, so that only the tests of the Core Rule Set have their requests
// inspected by it.
func newDiscriminator(
func newWAF(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
) (*discriminator.Server, *output, *alerts.Queue) {
) (*waf.Server, *output, *alerts.Queue) {
t.Helper()
return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
return newWAFWith(t, waf.New, geojsURL, now, env)
}
// newDiscriminatorWith is newDiscriminator for the server build makes.
func newDiscriminatorWith(
t *testing.T, build func(discriminator.Params) *discriminator.Server,
// newWAFWith is newWAF for the server build makes.
func newWAFWith(
t *testing.T, build func(waf.Params) *waf.Server,
geojsURL string, now func() time.Time, env map[string]string,
) (*discriminator.Server, *output, *alerts.Queue) {
) (*waf.Server, *output, *alerts.Queue) {
t.Helper()
settings := map[string]string{
@@ -466,12 +466,12 @@ func newDiscriminatorWith(
}
}
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
coreRuleSet, err := waf.NewCoreRuleSet(cfg)
if err != nil {
t.Fatalf("Core Rule Set: %v", err)
}
server := build(discriminator.Params{
server := build(waf.Params{
Config: cfg,
RequestLog: out,
ProcessLog: processLog,
@@ -516,7 +516,7 @@ func readAnswer(res *http.Response) answer {
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
}
// newRequest makes a request for path to addr, where rproxy or discriminator listens.
// newRequest makes a request for path to addr, where rproxy or waf listens.
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
t.Helper()
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
// SWW_BAN_RESPONSE.
@@ -11,7 +11,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// refusalLimit counts the request once it has been answered, if discriminator
// refusalLimit counts the request once it has been answered, if waf
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"maps"
@@ -10,14 +10,14 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
// refused is a request the tests here send, which discriminator refuses after a rule
// refused is a request the tests here send, which waf refuses after a rule
// file match.
type refused int
@@ -52,7 +52,7 @@ func (r refused) send(s *sender, from string) logLine {
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst(
t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *discriminator.Server) {
) (*sender, *clock, *waf.Server) {
t.Helper()
settings := map[string]string{
@@ -1,4 +1,4 @@
package discriminator
package waf
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
// abuseipdb.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"fmt"
@@ -15,10 +15,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// The reputation settings.
@@ -125,13 +125,13 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
for _, tc := range []struct {
heuristic string
env map[string]string
load func(*testing.T, *discriminator.Server)
load func(*testing.T, *waf.Server)
figures string
}{
{
"blocklists",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
func(t *testing.T, server *discriminator.Server) {
func(t *testing.T, server *waf.Server) {
t.Helper()
loadLists(t, server, map[string][]string{dropURL: {client}})
},
@@ -143,7 +143,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
},
func(_ *testing.T, server *discriminator.Server) {
func(_ *testing.T, server *waf.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
`{"zones": ["` + dnsblZone + `"]}`,
@@ -151,7 +151,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
func(_ *testing.T, server *discriminator.Server) {
func(_ *testing.T, server *waf.Server) {
loadScores(server, map[string]int64{client: 80})
},
`{"score": 80, "min_score": 75}`,
@@ -494,7 +494,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
for _, tc := range []struct {
name string
env map[string]string
load func(*discriminator.Server)
load func(*waf.Server)
want []string
}{
{
@@ -502,7 +502,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
},
func(server *discriminator.Server) {
func(server *waf.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
@@ -510,7 +510,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
func(server *discriminator.Server) {
func(server *waf.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=DE"},
@@ -715,7 +715,7 @@ const (
// AbuseIPDB.
const abuseipdb = reputation.AbuseIPDBSource
// abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at
// abuseIPDBURL is where newWAF has clients checked with AbuseIPDB: at
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
// through which AbuseIPDB is asked.
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
@@ -1075,7 +1075,7 @@ func verdictsFetched() time.Time {
// verdictsFetched, as reputation.json would at start: one that lists the
// client from each zone listedBy gives for it, and one that does not from
// each other zone.
func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
func loadVerdicts(server *waf.Server, listedBy map[string][]string) {
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
for client, zones := range listedBy {
@@ -1093,7 +1093,7 @@ func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
// loadScores puts into server's AbuseIPDB the score scores gives each
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
// would at start.
func loadScores(server *discriminator.Server, scores map[string]int64) {
func loadScores(server *waf.Server, scores map[string]int64) {
kept := make([]reputation.Score, 0, len(scores))
for client, score := range scores {
kept = append(kept, reputation.Score{
@@ -1107,7 +1107,7 @@ func loadScores(server *discriminator.Server, scores map[string]int64) {
// wantAbuseIPDBChecks checks how many clients server has checked with
// AbuseIPDB.
func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) {
t.Helper()
if got := server.AbuseIPDB.Checked(); got != want {
@@ -1117,7 +1117,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
// loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as reputation.json would at start.
func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
func loadLists(t *testing.T, server *waf.Server, copies map[string][]string) {
t.Helper()
lists := make([]reputation.List, 0, len(copies))
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"context"
@@ -17,7 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// refusal is discriminator refusing a request: the status the visitor is to get, 0
// refusal is waf refusing a request: the status the visitor is to get, 0
// to close its connection without an answer, and the action the log line names.
type refusal struct {
status int
@@ -25,7 +25,7 @@ type refusal struct {
}
// decision is what the checks note of a request: its log line, and what
// counting the request needs once it has ended. discriminator keeps a copy of it
// counting the request needs once it has ended. waf keeps a copy of it
// until the request's report comes. It holds no credentials, since the log line
// never does, and neither the request's objects nor their bodies.
type decision struct {
@@ -34,7 +34,7 @@ type decision struct {
// requestURI is the path with the query, as a ban's notes keep it.
requestURI string
// site is, for a question, the host the visitor asked for, which the
// alerts name, since one discriminator serves many sites.
// alerts name, since one waf serves many sites.
site string
// allowed is true for a question answered with DispositionAllow.
allowed bool
@@ -63,7 +63,7 @@ type decision struct {
reputation []bans.ReputationHit
}
// request is one request to discriminator, from the moment its headers have been
// request is one request to waf, from the moment its headers have been
// read to its log line.
type request struct {
decision
@@ -373,7 +373,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
}
// responseWriter is discriminator's answer to a request. It notes the status and
// responseWriter is waf's answer to a request. It notes the status and
// size for the log line, and the first error writing the answer.
type responseWriter struct {
http.ResponseWriter
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"encoding/json"
@@ -11,9 +11,9 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
@@ -105,7 +105,7 @@ func wantFullLine(t *testing.T, line logLine) {
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
want := withTimings(line, requestlog.Line{
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Type: requestType, WAFLine: requestlog.WAFLineReport,
Time: line.Time, Instance: instance,
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
Host: appHost, Path: "/full", Protocol: protocol,
@@ -131,7 +131,7 @@ func wantBareLine(t *testing.T, line logLine) {
t.Helper()
want := withTimings(line, requestlog.Line{
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Type: requestType, WAFLine: requestlog.WAFLineReport,
Time: line.Time, Instance: instance,
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
Host: appHost, Path: "/bare", Protocol: protocol,
@@ -221,7 +221,7 @@ func TestTimingsAreInOrder(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden)
refused := out.endedLines(t, 2)[1]
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
wantStatus(t, get(t, server.Addr, waf.HealthPath), http.StatusOK)
health := out.endedLines(t, 3)[2]
// A request passed to the app has every timing; one refused, none of
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"context"
@@ -10,8 +10,8 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
@@ -58,7 +58,7 @@ func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
// waitForRouteWatch waits until routewatch's answer about from, in the client
// group of the given length, is kept, so that the next request gets its tags.
func waitForRouteWatch(
t *testing.T, server *discriminator.Server, from string, bits int,
t *testing.T, server *waf.Server, from string, bits int,
) {
t.Helper()
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic tag_deny. It reads SWW_TAG_DENY.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
// the reputation lists.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator
package waf
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
@@ -1,4 +1,4 @@
package discriminator_test
package waf_test
import (
"net/http"
@@ -1,4 +1,4 @@
package discriminator
package waf
import (
"bytes"
@@ -18,8 +18,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The headers of rproxy's question and of discriminator's answer, as
// "discriminator" in SPEC.md gives them.
// The headers of rproxy's question and of waf's answer, as
// "waf" in SPEC.md gives them.
const (
// ClientHeader marks a request as rproxy's question, and gives the
// visitor's address.
@@ -28,7 +28,7 @@ const (
PeerHeader = "X-Sww-Peer"
// ContentLengthHeader gives the body length the visitor announced.
ContentLengthHeader = "X-Sww-Content-Length"
// DispositionHeader gives discriminator's answer.
// DispositionHeader gives waf's answer.
DispositionHeader = "X-Sww-Disposition"
)
@@ -41,12 +41,12 @@ const (
DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
// discriminator closing the question's connection instead would read as
// discriminator being down, and rproxy would then let the request through.
// waf closing the question's connection instead would read as
// waf being down, and rproxy would then let the request through.
DispositionClose = "close"
)
// ReportPath is where rproxy sends the report of each request discriminator
// ReportPath is where rproxy sends the report of each request waf
// answered.
const ReportPath = "/_sww/report"
@@ -70,11 +70,11 @@ const (
// maxReportedMilliseconds is the longest duration a report may give,
// about 31 years, so that it fits a time.Duration.
maxReportedMilliseconds = 1e12
// discriminatorTimeout bounds how long a request to discriminator takes to
// arrive, and its answer to leave, and discriminatorIdleTimeout how long a
// wafTimeout bounds how long a request to waf takes to
// arrive, and its answer to leave, and wafIdleTimeout how long a
// kept-open connection waits for its next request.
discriminatorTimeout = 60 * time.Second
discriminatorIdleTimeout = 120 * time.Second
wafTimeout = 60 * time.Second
wafIdleTimeout = 120 * time.Second
)
var (
@@ -97,7 +97,7 @@ type Report struct {
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
}
// New returns the server `sww discriminator` runs. It answers
// New returns the server `sww waf` runs. It answers
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
// passes nothing to an app. rproxy applies the size and time limits to the
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
@@ -111,9 +111,9 @@ func New(params Params) *Server {
Addr: params.Config.ListenAddr,
Handler: h,
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
ReadTimeout: discriminatorTimeout,
WriteTimeout: discriminatorTimeout,
IdleTimeout: discriminatorIdleTimeout,
ReadTimeout: wafTimeout,
WriteTimeout: wafTimeout,
IdleTimeout: wafIdleTimeout,
},
Ledger: h.ledger,
Limiter: h.limiter,
@@ -128,8 +128,8 @@ func New(params Params) *Server {
}
}
// ServeHTTP handles one request to discriminator: a request carrying
// ClientHeader is rproxy's question, and any other is for discriminator
// ServeHTTP handles one request to waf: a request carrying
// ClientHeader is rproxy's question, and any other is for waf
// itself.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 {
@@ -145,8 +145,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: the heuristic crowdsec, blocklists or rate_limits will refuse it,
// as a function in that heuristic's file says. In observe mode, which refuses
// nothing, none is. rproxy lets a request through when discriminator answers too
// late.
// nothing, none is. rproxy lets a request through when waf answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe {
return false
@@ -179,7 +178,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
defer rq.finish()
rq.site = r.Host
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
rq.line.WAFLine = requestlog.WAFLineDecision
refused := rq.check(r.Context())
rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
@@ -205,7 +204,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return
}
// The app never sees this prefix. discriminator's own endpoints are at its
// The app never sees this prefix. waf's own endpoints are at its
// own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionOwnEndpoint
rq.out.Header().Set(DispositionHeader, DispositionDeny)
@@ -213,7 +212,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
}
// readQuestion returns the visitor's address and the body a question carries,
// and whether discriminator takes the question. It answers a question it does not
// and whether waf takes the question. It answers a question it does not
// take with 400 and no disposition, which rproxy treats as it treats no
// answer. The id of a question it takes is held from here on, without a
// decision yet, so that another question with that id is refused while this
@@ -256,7 +255,7 @@ func (h *handler) readQuestion(
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
if held {
h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
h.refuseQuestion(w, requestIDHeader+" is the id of a request waf holds")
return netip.Addr{}, nil, false
}
@@ -264,14 +263,14 @@ func (h *handler) readQuestion(
return client, body, true
}
// refuseQuestion answers a question discriminator does not take, for reason.
// refuseQuestion answers a question waf does not take, for reason.
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
h.processLog.Warn("a question is refused", "reason", reason)
http.Error(w, reason, http.StatusBadRequest)
}
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for discriminator,
// log line are to see it: without the headers rproxy adds for waf,
// with body, which has been read of r, and with the length the visitor
// announced, as -1 when it announced none. The question's own length covers
// only the part of the body it carries.
@@ -339,7 +338,7 @@ func (rq *request) deny(r refusal) {
http.Error(rq.out, http.StatusText(r.status), r.status)
}
// answerOwnEndpoint answers a request to discriminator's own address. No check
// answerOwnEndpoint answers a request to waf's own address. No check
// runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
@@ -454,7 +453,7 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
requestBytes: report.RequestBytes,
}
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
rq.line.WAFLine = requestlog.WAFLineReport
rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes
File diff suppressed because it is too large. Load diff
+60 -60
View File
@@ -1,25 +1,25 @@
#!/bin/sh
# script/example-app: build the image, and on it the example app in
# deploy/example-app, then run the app's container, with rproxy in it, and
# discriminator in a container of its own with a volume for the state files. It
# waf in a container of its own with a volume for the state files. It
# checks:
# - that the health checks pass;
# - that a request is served through rproxy, and a second one in a minute
# bans the client;
# - that a probe for /.env bans another client, which its next request bans
# for good;
# - that only discriminator's container changes the state directory's owner;
# - that the app still answers through rproxy with discriminator stopped, and
# that once discriminator has started again, bans apply again;
# - that only waf's container changes the state directory's owner;
# - that the app still answers through rproxy with waf stopped, and
# that once waf has started again, bans apply again;
# - that `sv stop` stops rproxy in order, and `docker stop` stops each
# container without having to kill it;
# - that a new discriminator container on the same volume still refuses the banned
# - that a new waf container on the same volume still refuses the banned
# client;
# - that a discriminator container with its state files on a new volume mounted at
# - that a waf container with its state files on a new volume mounted at
# the directory SWW_STATE_DIR_FILE names becomes healthy;
# - that one whose SWW_STATE_DIR does not exist logs discriminator's message
# - that one whose SWW_STATE_DIR does not exist logs waf's message
# about it.
# discriminator runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to
# waf runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to
# GeoJS. The containers, the network, the volumes and both images are removed
# however the script ends. Building the app needs network access, for nixpkgs'
# binary cache. script/check does not run this.
@@ -35,7 +35,7 @@ IMAGE="$NAME-base"
APP_IMAGE="$NAME-app"
NETWORK="$NAME"
APP="$NAME-app-container"
DISCRIMINATOR="$NAME-discriminator"
WAF="$NAME-waf"
VOLUME="$NAME-state"
# The volumes of the container whose state directory SWW_STATE_DIR_FILE
# names: its state files, and the file that names their directory.
@@ -43,7 +43,7 @@ MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings"
cleanup() {
docker rm --force "$APP" "$DISCRIMINATOR" >/dev/null 2>&1 || true
docker rm --force "$APP" "$WAF" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
>/dev/null 2>&1 || true
@@ -53,8 +53,8 @@ cleanup() {
fail() {
echo "example-app: $*; the app container's output:" >&2
docker logs "$APP" >&2 || true
echo "example-app: discriminator's output:" >&2
docker logs "$DISCRIMINATOR" >&2 || true
echo "example-app: waf's output:" >&2
docker logs "$WAF" >&2 || true
exit 1
}
@@ -87,49 +87,49 @@ logged() {
done
}
# start_discriminator: run discriminator from the image, with the state files on the
# start_waf: run waf from the image, with the state files on the
# volume, a rate limit of one request a minute and no client looked up, and wait
# until it is healthy.
start_discriminator() {
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
start_waf() {
docker run --detach --name "$WAF" --network "$NETWORK" \
--volume "$VOLUME:/var/lib/sww" \
--env SWW_COMMAND=discriminator \
--env SWW_COMMAND=waf \
--env SWW_RATE_LIMIT_PER_MINUTE=1 \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
wait_for "waf's health check did not pass" healthy "$WAF"
}
# start_app: run the app's container, with rproxy in it, and wait until it is
# healthy.
start_app() {
docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \
--env SWRP_WAF_BACKEND="http://$DISCRIMINATOR:8080" \
--env SWRP_WAF_BACKEND="http://$WAF:8080" \
"$APP_IMAGE" >/dev/null
wait_for "the app's health check did not pass" healthy "$APP"
address="$(docker port "$APP" 8080/tcp)"
}
# start_moved_discriminator: run discriminator with the state files on a new volume at
# start_moved_waf: run waf with the state files on a new volume at
# /srv/state, which SWW_STATE_DIR_FILE names in a file only the sww
# user can read, as under "Settings given as files" in docs/settings.md, and
# wait until it is healthy. discriminator can write the new volume only once the run
# wait until it is healthy. waf can write the new volume only once the run
# script has given it to that user.
start_moved_discriminator() {
start_moved_waf() {
docker volume create "$MOVED_VOLUME" >/dev/null
docker volume create "$SETTINGS_VOLUME" >/dev/null
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir &&
chown sww:sww /settings/state-dir &&
chmod 0400 /settings/state-dir'
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
docker run --detach --name "$WAF" --network "$NETWORK" \
--volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
--env SWW_COMMAND=discriminator \
--env SWW_COMMAND=waf \
--env SWW_STATE_DIR_FILE=/etc/sww/settings/state-dir \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
wait_for "waf's health check did not pass" healthy "$WAF"
}
# status_from <client> <path>: print the status a request for path from client,
@@ -169,87 +169,87 @@ main() {
docker network create "$NETWORK" >/dev/null
docker volume create "$VOLUME" >/dev/null
start_discriminator
start_waf
start_app
echo "example-app: the health checks pass"
[ "$(owner_of_state_dir "$DISCRIMINATOR")" = sww ] ||
fail "discriminator's container did not give the state directory to sww"
[ "$(owner_of_state_dir "$WAF")" = sww ] ||
fail "waf's container did not give the state directory to sww"
[ "$(owner_of_state_dir "$APP")" = root ] ||
fail "the app's container changed the state directory's owner"
echo "example-app: only discriminator's container changes the state directory's owner"
echo "example-app: only waf's container changes the state directory's owner"
page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
fail "no answer on port 8080"
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
wait_for "discriminator logged no request it allowed" \
logged "$DISCRIMINATOR" '"action":"forward"' '"discriminator_line":"report"'
wait_for "waf logged no request it allowed" \
logged "$WAF" '"action":"forward"' '"waf_line":"report"'
echo "example-app: rproxy passes a request to the app and its answer back"
refused || fail "a second request in a minute was not refused"
wait_for "discriminator logged no ban" \
logged "$DISCRIMINATOR" '"action":"client_is_rate_limited"'
wait_for "waf logged no ban" \
logged "$WAF" '"action":"client_is_rate_limited"'
echo "example-app: a second request in a minute bans the client"
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
wait_for "discriminator logged no ban for the probe" \
logged "$DISCRIMINATOR" '"action":"temporarily_banned"' '"rule_ids":["env-file"]'
wait_for "waf logged no ban for the probe" \
logged "$WAF" '"action":"temporarily_banned"' '"rule_ids":["env-file"]'
refused_from 203.0.113.9 / || fail "the client of the probe was let through"
wait_for "the client's next request did not make its ban permanent" \
logged "$DISCRIMINATOR" '"action":"permanently_banned"' '"ban_expires":"permanent"'
logged "$WAF" '"action":"permanently_banned"' '"ban_expires":"permanent"'
echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$DISCRIMINATOR" sv stop sww >/dev/null ||
fail "sv stop sww failed in discriminator's container"
wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"'
docker exec "$WAF" sv stop sww >/dev/null ||
fail "sv stop sww failed in waf's container"
wait_for "waf did not stop in order" logged "$WAF" '"msg":"stopped"'
[ "$(status_from 203.0.113.9 /)" = 200 ] ||
fail "with discriminator stopped, the app did not answer through rproxy"
fail "with waf stopped, the app did not answer through rproxy"
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged "$APP" '"default_disposition":true'
docker exec "$APP" /usr/local/bin/sww healthcheck ||
fail "with discriminator stopped, the app's health check failed"
echo "example-app: with discriminator stopped, the app answers through rproxy"
fail "with waf stopped, the app's health check failed"
echo "example-app: with waf stopped, the app answers through rproxy"
docker exec "$DISCRIMINATOR" sv start sww >/dev/null ||
fail "sv start sww failed in discriminator's container"
wait_for "once discriminator had started again, the ban did not apply" \
docker exec "$WAF" sv start sww >/dev/null ||
fail "sv start sww failed in waf's container"
wait_for "once waf had started again, the ban did not apply" \
refused_from 203.0.113.9 /
echo "example-app: once discriminator has started again, bans apply again"
echo "example-app: once waf has started again, bans apply again"
docker exec "$APP" sv stop sww >/dev/null ||
fail "sv stop sww failed in the app's container"
wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"'
echo "example-app: sv stop stops rproxy in order"
for container in "$APP" "$DISCRIMINATOR"; do
for container in "$APP" "$WAF"; do
docker stop "$container" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$container")"
[ "$status" = 0 ] || fail "docker stop left exit status $status"
done
echo "example-app: docker stop stops each container in order"
docker rm "$APP" "$DISCRIMINATOR" >/dev/null
start_discriminator
docker rm "$APP" "$WAF" >/dev/null
start_waf
start_app
refused || fail "the new discriminator container let the banned client through"
wait_for "discriminator logged no request refused under the ban" \
logged "$DISCRIMINATOR" '"action":"temporarily_banned"'
echo "example-app: a new discriminator container on the same volume keeps the ban"
refused || fail "the new waf container let the banned client through"
wait_for "waf logged no request refused under the ban" \
logged "$WAF" '"action":"temporarily_banned"'
echo "example-app: a new waf container on the same volume keeps the ban"
docker rm --force "$DISCRIMINATOR" >/dev/null
start_moved_discriminator
docker rm --force "$WAF" >/dev/null
start_moved_waf
echo "example-app: the run script gives the state files' directory named" \
"by SWW_STATE_DIR_FILE to the sww user"
docker rm --force "$DISCRIMINATOR" >/dev/null
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
--env SWW_COMMAND=discriminator \
docker rm --force "$WAF" >/dev/null
docker run --detach --name "$WAF" --network "$NETWORK" \
--env SWW_COMMAND=waf \
--env SWW_STATE_DIR=/srv/missing \
--env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null
wait_for "discriminator did not report the missing state directory" \
logged "$DISCRIMINATOR" "SWW_STATE_DIR cannot be written"
echo "example-app: discriminator reports a state directory that does not exist"
wait_for "waf did not report the missing state directory" \
logged "$WAF" "SWW_STATE_DIR cannot be written"
echo "example-app: waf reports a state directory that does not exist"
}
main "$@"
+7 -7
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# script/run: build bin/sww with script/build and run discriminator and
# script/run: build bin/sww with script/build and run waf and
# rproxy on the host, with the settings in the environment, until rproxy
# exits or the script is told to stop. rproxy listens on port 8080 unless
# SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL.
# discriminator listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and
# waf listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and
# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless
# SWW_STATE_DIR is set, the state files go in bin/state, beside the binary,
# and unless SWW_RULES_DIR is set, the rule files are those of share/rules.d,
@@ -13,14 +13,14 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
DISCRIMINATOR=""
WAF=""
RPROXY=""
# stop_both: stop what runs, and wait for it, so that discriminator writes its state
# stop_both: stop what runs, and wait for it, so that waf writes its state
# files.
stop_both() {
trap '' HUP INT TERM
kill $DISCRIMINATOR $RPROXY 2>/dev/null || true
kill $WAF $RPROXY 2>/dev/null || true
wait || true
}
@@ -42,8 +42,8 @@ main() {
trap stop_both EXIT
trap 'exit 1' HUP INT TERM
"$ROOT/bin/sww" discriminator &
DISCRIMINATOR=$!
"$ROOT/bin/sww" waf &
WAF=$!
"$ROOT/bin/sww" rproxy &
RPROXY=$!
wait "$RPROXY"
+5 -5
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# runit's run script for sww, run again whenever sww
# exits; the wait spaces out the restarts. SWW_COMMAND names the
# command to run: rproxy when it is unset or empty, or discriminator. With any
# command to run: rproxy when it is unset or empty, or waf. With any
# other value the script starts nothing and stops with a message naming it.
# exec, so that the signal `sv stop` sends reaches sww itself.
main() {
@@ -11,9 +11,9 @@ main() {
local command="${SWW_COMMAND:-rproxy}"
case "$command" in
rproxy) ;;
discriminator) give_state_dir_to_sww ;;
waf) give_state_dir_to_sww ;;
*)
printf 'SWW_COMMAND: "%s" is not rproxy or discriminator\n' \
printf 'SWW_COMMAND: "%s" is not rproxy or waf\n' \
"$command" >&2
exit 1
;;
@@ -25,9 +25,9 @@ main() {
# it to the sww user, so that a volume mounted there needs no change
# of owner; chown -R changes a symbolic link itself, never what it points
# to. When SWW_STATE_DIR, in either form, cannot be read or is not the
# absolute path of a directory, no owner is changed, and discriminator starts and
# absolute path of a directory, no owner is changed, and waf starts and
# stops with a message naming the setting. A change of owner that fails, as
# on a volume mounted read-only, does not end the script either: discriminator
# on a volume mounted read-only, does not end the script either: waf
# reports a directory it cannot write.
give_state_dir_to_sww() {
local dir