Rename discriminator to waf everywhere (closes #312) #319
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
@@ -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 tag lists.
|
||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||
@@ -528,13 +526,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
|
||||
@@ -560,8 +558,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`
|
||||
@@ -612,17 +610,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
|
||||
@@ -632,20 +630,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
|
||||
|
||||
@@ -653,8 +650,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
|
||||
@@ -734,25 +731,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.
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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="
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
-1
@@ -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"
|
||||
@@ -15,16 +15,16 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/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
|
||||
@@ -33,7 +33,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.
|
||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
||||
}
|
||||
|
||||
// fillHeaders adds headers to a question until the checks see total header
|
||||
// fields: all but ClientHeader and PeerHeader, which 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) {
|
||||
@@ -70,13 +70,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)
|
||||
@@ -89,7 +89,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)
|
||||
@@ -143,7 +143,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)
|
||||
|
||||
@@ -151,13 +151,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 {
|
||||
@@ -178,11 +178,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -190,23 +190,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 ||
|
||||
@@ -219,7 +219,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
ask := func() answer {
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -227,8 +227,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), tooLarge, waf.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, waf.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
@@ -246,13 +246,13 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
s := startWAF(t, "", map[string]string{
|
||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
req := s.question(siteA, client, tc.path)
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||
})
|
||||
}
|
||||
@@ -262,13 +262,13 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
@@ -278,14 +278,14 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// The client is under no ban, and GeoJS places it in a country.
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, waf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s := startWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s.server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||
@@ -294,7 +294,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
}
|
||||
@@ -303,14 +303,14 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := 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)
|
||||
|
||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||
if asn != asnDE || country != "DE" {
|
||||
@@ -319,18 +319,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(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
|
||||
}
|
||||
@@ -338,13 +338,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)
|
||||
|
||||
@@ -357,17 +357,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{{
|
||||
@@ -379,13 +379,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()
|
||||
@@ -396,7 +396,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
|
||||
@@ -443,7 +443,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) {
|
||||
@@ -471,7 +471,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
|
||||
}{
|
||||
@@ -500,7 +500,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) {})
|
||||
@@ -516,7 +516,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -531,12 +531,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.
|
||||
@@ -70,7 +70,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 {
|
||||
@@ -146,7 +146,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// its client: the heuristic crowdsec, header_limits, 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.
|
||||
// waf answers too late.
|
||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||
if rq.h.config.Observe {
|
||||
return false
|
||||
@@ -180,7 +180,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)
|
||||
@@ -206,7 +206,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)
|
||||
@@ -214,7 +214,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
|
||||
@@ -257,7 +257,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
|
||||
}
|
||||
@@ -265,14 +265,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.
|
||||
@@ -340,7 +340,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) {
|
||||
@@ -455,7 +455,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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user