Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c7b98645 |
No files matched your search
@@ -30,7 +30,7 @@ These are the rules for agents working on this repository.
|
|||||||
- Secrets belong in POST data. An app that puts a secret in a query string
|
- Secrets belong in POST data. An app that puts a secret in a query string
|
||||||
is fixed in the app.
|
is fixed in the app.
|
||||||
- Never propose redacting a query string, Gitea's `?token=` included.
|
- Never propose redacting a query string, Gitea's `?token=` included.
|
||||||
- The default of `SWW_RPROXY_TRUSTED_PROXIES` is the RFC 1918 set.
|
- The default of `SWRP_TRUSTED_PROXIES` is the RFC 1918 set.
|
||||||
- The set is:
|
- The set is:
|
||||||
- `10.0.0.0/8`;
|
- `10.0.0.0/8`;
|
||||||
- `172.16.0.0/12`;
|
- `172.16.0.0/12`;
|
||||||
|
|||||||
+2
-2
@@ -221,12 +221,12 @@ RUN groupadd --system --gid 65532 sww \
|
|||||||
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||||
sww
|
sww
|
||||||
|
|
||||||
# The state files' directory, SWW_WAF_STATE_DIR by default, where a volume
|
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||||
# is mounted to keep them across deploys. The run script gives it to the
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# sww user at each start of waf.
|
# sww user at each start of waf.
|
||||||
RUN mkdir /var/lib/sww
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ 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
|
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||||
it. It asks `waf` about each request, passes the allowed ones to the app, and
|
it. It asks `waf` about each request, passes the allowed ones to the app, and
|
||||||
passes the app's answers back. It keeps nothing between requests and writes
|
passes the app's answers back. It keeps nothing and never touches disk.
|
||||||
nothing to disk.
|
|
||||||
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
|
- `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
|
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
|
one site is refused on all of them, and its requests on all of them count
|
||||||
@@ -91,9 +90,8 @@ accepts connections, whether or not `waf` answers; in `waf`'s container, once
|
|||||||
The usual setup is three containers on one private network:
|
The usual setup is three containers on one private network:
|
||||||
|
|
||||||
- the app;
|
- the app;
|
||||||
- `rproxy`, in front of the app, which traefik routes to:
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||||
`SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_UPSTREAM_URL` names the
|
names `waf` and `SWRP_UPSTREAM_URL` names the app;
|
||||||
app;
|
|
||||||
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
|
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
|
||||||
files.
|
files.
|
||||||
|
|
||||||
@@ -102,8 +100,8 @@ Point traefik's labels at port 8080 of `rproxy`
|
|||||||
of the app or of `waf`. Anything on that network can reach the app around
|
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.
|
`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
|
Each further app adds its own container and its own `rproxy`, whose
|
||||||
`SWW_RPROXY_WAF_BACKEND` names the same `waf`. The "Smallest setup" example
|
`SWRP_WAF_BACKEND` names the same `waf`. The "Smallest setup" example below
|
||||||
below shows this shape.
|
shows this shape.
|
||||||
|
|
||||||
### `rproxy` in the app's container
|
### `rproxy` in the app's container
|
||||||
|
|
||||||
@@ -116,7 +114,7 @@ digest, and adds:
|
|||||||
- the packages the app needs, from the nixpkgs in the image;
|
- the packages the app needs, from the nixpkgs in the image;
|
||||||
- a user for the app;
|
- a user for the app;
|
||||||
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default
|
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default
|
||||||
of `SWW_RPROXY_UPSTREAM_URL`.
|
of `SWRP_UPSTREAM_URL`.
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# The smallwebwaf image, pinned by digest.
|
# The smallwebwaf image, pinned by digest.
|
||||||
@@ -152,8 +150,8 @@ main "$@"
|
|||||||
|
|
||||||
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
|
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
|
||||||
entrypoint starts `rproxy` and the app side by side, each as its own user. Run
|
entrypoint starts `rproxy` and the app side by side, each as its own user. Run
|
||||||
the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
|
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
|
||||||
an app, and the "`rproxy` beside the app" example below runs one.
|
app, and the "`rproxy` beside the app" example below runs one.
|
||||||
|
|
||||||
The app sees the headers traefik sent, with traefik's address added to
|
The app sees the headers traefik sent, with traefik's address added to
|
||||||
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
|
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
|
||||||
@@ -172,13 +170,12 @@ does.
|
|||||||
- A request to the site through traefik gets the app's answer.
|
- A request to the site through traefik gets the app's answer.
|
||||||
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
|
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
|
||||||
request have `"action":"forward"`.
|
request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `waf` with
|
- To see a refusal without banning yourself, run `waf` with `SWW_MODE=observe`
|
||||||
`SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
and ask for `/.env`. The request reaches the app. `waf`'s log lines have
|
||||||
`waf`'s log lines have `"would_action":"temporarily_banned"` and
|
`"would_action":"temporarily_banned"` and `"rule_ids":["env-file"]`. A ban you
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
get in `enforce` mode is lifted as "Clients and bans" below describes.
|
||||||
"Clients and bans" below describes.
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
- With `SWW_WAF_METRICS_TOKEN` set, this answers with the metrics, from a host
|
`waf`'s network:
|
||||||
on `waf`'s network:
|
|
||||||
`curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/metrics`.
|
`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
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||||
the whole exchange (see "Entrypoints").
|
the whole exchange (see "Entrypoints").
|
||||||
@@ -186,17 +183,18 @@ does.
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Every setting is an environment variable, read once at start; changing one means
|
Every setting is an environment variable, read once at start; changing one means
|
||||||
restarting the container. `rproxy` reads only the `SWW_RPROXY_` settings, and
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and `waf`
|
||||||
`waf` only the `SWW_WAF_` ones. Every setting but `SWW_RPROXY_WAF_BACKEND` has a
|
only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen
|
||||||
default, chosen for a service on the open internet. A setting that needs an
|
for a service on the open internet. A setting that needs an account, a key, a
|
||||||
account, a key, a token or an alert destination stays off until you give one.
|
token or an alert destination stays off until you give one.
|
||||||
|
|
||||||
- Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for
|
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||||
secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds
|
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||||
`SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at
|
contents are the value, less one newline at the end. Setting both forms stops
|
||||||
the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE`
|
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||||
and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have
|
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||||
no `_FILE` form. `SWW_COMMAND` cannot be given as a file.
|
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||||
|
one, since `rproxy` never touches disk.
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||||
1024: `512K`, `100M`, `5G`.
|
1024: `512K`, `100M`, `5G`.
|
||||||
@@ -212,133 +210,131 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
setting takes it.
|
setting takes it.
|
||||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
app's response bodies, the visitors' request bodies, or both, as
|
||||||
`SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
|
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
||||||
over a WebSocket counts as request bytes, and what the app sent as response
|
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
||||||
bytes.
|
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- An invalid setting stops the process at start with one log line naming the
|
||||||
variable and what is wrong with the value. The settings in effect are logged
|
variable and what is wrong with the value. The settings in effect are logged
|
||||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||||
|
|
||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
||||||
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
|
||||||
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||||
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
| `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. |
|
||||||
| `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWW_RPROXY_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_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`. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
| `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. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
| `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. |
|
||||||
| `SWW_RPROXY_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
| `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_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
|
||||||
| `SWW_WAF_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_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_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` 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_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||||
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_WAF_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
||||||
| `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
||||||
| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
||||||
| `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||||
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWW_WAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
| `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
| `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||||
| `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
| `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||||
| `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||||
| `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWW_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. |
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
|
| `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_WAF_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_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWW_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_WAF_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_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||||
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWW_WAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||||
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWW_WAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
||||||
| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
||||||
| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -365,8 +361,8 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
SWRP_WAF_BACKEND: http://waf:8080
|
||||||
SWW_RPROXY_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -401,8 +397,8 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
SWRP_WAF_BACKEND: http://waf:8080
|
||||||
SWW_RPROXY_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -411,22 +407,22 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: waf
|
SWW_COMMAND: waf
|
||||||
# Monitoring skips every check.
|
# Monitoring skips every check.
|
||||||
SWW_WAF_ALLOW_NETS: 192.0.2.10
|
SWW_ALLOW_NETS: 192.0.2.10
|
||||||
# The office and the automation host are never rate limited.
|
# The office and the automation host are never rate limited.
|
||||||
SWW_WAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||||
# A netblock that sends nothing wanted.
|
# A netblock that sends nothing wanted.
|
||||||
SWW_WAF_DENY_NETS: 198.51.100.128/25
|
SWW_DENY_NETS: 198.51.100.128/25
|
||||||
# Tighter limits for everyone else: a small static site.
|
# Tighter limits for everyone else: a small static site.
|
||||||
SWW_WAF_RATE_LIMIT_PER_MINUTE: "60"
|
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||||
SWW_WAF_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_WAF_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_WAF_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_WAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
SWW_WAF_BAN_RESPONSE: "429"
|
SWW_BAN_RESPONSE: "429"
|
||||||
SWW_WAF_LIMIT_BAN_DURATION: 30m
|
SWW_LIMIT_BAN_DURATION: 30m
|
||||||
volumes:
|
volumes:
|
||||||
- waf-state:/var/lib/sww
|
- waf-state:/var/lib/sww
|
||||||
|
|
||||||
@@ -452,9 +448,9 @@ services:
|
|||||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||||
build: ./app
|
build: ./app
|
||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
SWRP_WAF_BACKEND: http://waf:8080
|
||||||
# Answer 503 rather than pass a request on while waf is down.
|
# Answer 503 rather than pass a request on while waf is down.
|
||||||
SWW_RPROXY_DEFAULT_DISPOSITION: deny
|
SWRP_DEFAULT_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -462,16 +458,16 @@ services:
|
|||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: waf
|
SWW_COMMAND: waf
|
||||||
SWW_WAF_INSTANCE_NAME: fsn1/waf
|
SWW_INSTANCE_NAME: fsn1/waf
|
||||||
# Refuse nothing yet; log what enforce mode would have done.
|
# Refuse nothing yet; log what enforce mode would have done.
|
||||||
SWW_WAF_MODE: observe
|
SWW_MODE: observe
|
||||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||||
SWW_WAF_LOOKUP_SOURCE: file
|
SWW_LOOKUP_SOURCE: file
|
||||||
SWW_WAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||||
# Alerts to an ntfy topic, with its token read from a file.
|
# Alerts to an ntfy topic, with its token read from a file.
|
||||||
SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||||
SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||||
SWW_WAF_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
||||||
volumes:
|
volumes:
|
||||||
- waf-state:/var/lib/sww
|
- waf-state:/var/lib/sww
|
||||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||||
@@ -508,19 +504,19 @@ request reaches the app.
|
|||||||
|
|
||||||
1. The health check, `GET /_sww/healthz`, is answered at once.
|
1. The health check, `GET /_sww/healthz`, is answered at once.
|
||||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||||
from `SWW_RPROXY_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the visitor
|
||||||
visitor sent it, with the first 64 KiB of its body.
|
sent it, with the first 64 KiB of its body.
|
||||||
|
|
||||||
`waf` takes the checks:
|
`waf` takes the checks:
|
||||||
|
|
||||||
3. The header limits, then the tag lists. A client in `SWW_WAF_ALLOW_NETS` skips
|
3. The header limits, then the tag lists. A client in `SWW_ALLOW_NETS` skips the
|
||||||
the header limits.
|
header limits.
|
||||||
4. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`.
|
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
5. The bans.
|
5. The bans.
|
||||||
6. The lookup of the client's AS number and country.
|
6. The lookup of the client's AS number and country.
|
||||||
7. The country list.
|
7. The country list.
|
||||||
8. The reputation sources.
|
8. The reputation sources.
|
||||||
9. The tag refusals of `SWW_WAF_TAG_DENY`.
|
9. The tag refusals of `SWW_TAG_DENY`.
|
||||||
10. The client's limit percentage, from its tags, its AS number, its country or
|
10. The client's limit percentage, from its tags, its AS number, its country or
|
||||||
a reputation hit.
|
a reputation hit.
|
||||||
11. The rate limits.
|
11. The rate limits.
|
||||||
@@ -536,23 +532,23 @@ request reaches the app.
|
|||||||
17. The request is passed to the app, streaming.
|
17. The request is passed to the app, streaming.
|
||||||
18. After the answer, `rproxy` sends `waf` a report with the status and the body
|
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
|
bytes each way. For an allowed request, `waf` counts the bytes
|
||||||
`SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
|
`SWW_BYTES_COUNT` names toward the byte limits, bans the client if they take
|
||||||
take it past one, and adds the request to the client's history.
|
it past one, and adds the request to the client's history.
|
||||||
|
|
||||||
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
instead. A request over the header limits of step 3 is answered `431`, and bans
|
request over the header limits of step 3 is answered `431`, and bans nobody
|
||||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
and the status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
### Clients and bans
|
### Clients and bans
|
||||||
|
|
||||||
- A client is one IPv4 address or one IPv6 netblock of
|
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||||
`SWW_WAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
since one abuser usually controls a whole /64.
|
||||||
- Each limit is counted over a sliding window with a few integers per client.
|
- Each limit is counted over a sliding window with a few integers per client.
|
||||||
The table of clients and the table of bans each have a bound
|
The table of clients and the table of bans each have a bound
|
||||||
(`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_MAX_BANS`), so memory is bounded too.
|
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||||
lifts it.
|
lifts it.
|
||||||
@@ -567,24 +563,24 @@ Each request gets tags from what `waf` knows of its client, written in the
|
|||||||
`tags` field of its log line:
|
`tags` field of its log line:
|
||||||
|
|
||||||
- `asn=<number>` and `country=<code>`, from the lookup;
|
- `asn=<number>` and `country=<code>`, from the lookup;
|
||||||
- one tag per list of `SWW_WAF_TAG_LISTS` that holds the client, such as
|
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
||||||
`torexit` or `vpn=mullvad`;
|
or `vpn=mullvad`;
|
||||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||||
the client;
|
the client;
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
||||||
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
||||||
`SWW_WAF_ROUTEWATCH_URL` is set.
|
`SWW_ROUTEWATCH_URL` is set.
|
||||||
|
|
||||||
`SWW_WAF_TAG_DENY` refuses requests by tag, and `SWW_WAF_TAG_LIMIT_PERCENT`
|
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
||||||
lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
carry what people write and publish, which the rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary
|
- `smallwebwaf` changes the Core Rule Set in fifteen ways so that ordinary
|
||||||
requests get through to the apps listed below. No setting undoes the changes;
|
requests get through to the apps listed below. No setting undoes the changes;
|
||||||
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||||
- These apps work out of the box at the default settings, and their recorded
|
- These apps work out of the box at the default settings, and their recorded
|
||||||
@@ -592,6 +588,9 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
- gitea: git over HTTP and sign-in through Git Credential Manager get
|
- gitea: git over HTTP and sign-in through Git Credential Manager get
|
||||||
through. On gitea's own paths, its searches, file names and branch names
|
through. On gitea's own paths, its searches, file names and branch names
|
||||||
do too.
|
do too.
|
||||||
|
- Forgejo: git over HTTP and its container and package registries get
|
||||||
|
through. On Forgejo's own paths, its searches, file names and branch names
|
||||||
|
do too.
|
||||||
- WordPress: saving a post from the block editor gets through. On
|
- WordPress: saving a post from the block editor gets through. On
|
||||||
WordPress's own paths, its searches, names and file names do too.
|
WordPress's own paths, its searches, names and file names do too.
|
||||||
- Drupal: on Drupal's own paths, the page to return to, its searches and its
|
- Drupal: on Drupal's own paths, the page to return to, its searches and its
|
||||||
@@ -612,35 +611,21 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
- deleting the account, on gitea;
|
- deleting the account, on gitea;
|
||||||
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
||||||
password, not the password itself.
|
password, not the password itself.
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
own fields or paths.
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
while `waf` runs. The image ships one file of probes no real visitor sends
|
while `waf` runs. The image ships one file of probes no real visitor sends
|
||||||
(see [`docs/rules.md`](docs/rules.md)).
|
(see [`docs/rules.md`](docs/rules.md)).
|
||||||
|
|
||||||
### State and cache files
|
### State files
|
||||||
|
|
||||||
`waf` keeps everything in memory, and JSON files hold a copy:
|
`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
|
||||||
- The state files, in `SWW_WAF_STATE_DIR`, hold what cannot be fetched again:
|
`alerts.json`. Each is written whole, to a temporary file renamed over the real
|
||||||
- `bans.json`;
|
one, so a crash leaves a complete file. `waf` notices a file edited on disk and
|
||||||
- `clients.json`;
|
takes the edit in; an edit that does not parse is set aside as `<name>.bad`, and
|
||||||
- `abuseipdb-checks-spent.json`;
|
never stops the running process (see
|
||||||
- `alerts.json`.
|
[`docs/state-files.md`](docs/state-files.md)).
|
||||||
- The cache files, in `SWW_WAF_CACHE_DIR`, hold answers from outside services:
|
|
||||||
- `lookups.json`, from GeoJS;
|
|
||||||
- `lists.json`, the lists fetched by URL;
|
|
||||||
- `dnsbl.json`, from the DNS blocklists;
|
|
||||||
- `abuseipdb-scores.json`, from AbuseIPDB;
|
|
||||||
- `routewatch.json`, from routewatch.
|
|
||||||
|
|
||||||
Back up the state directory. The cache directory can be left out of backups: a
|
|
||||||
lost cache file starts empty and fills again.
|
|
||||||
|
|
||||||
Each file 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
|
### The image and its processes
|
||||||
|
|
||||||
@@ -651,7 +636,7 @@ stops the running process (see [`docs/state-files.md`](docs/state-files.md)).
|
|||||||
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
- 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 `waf` gives the state
|
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
|
directory to the `sww` user at each start, so a volume needs no change of
|
||||||
owner. It makes the cache directory for that user too.
|
owner.
|
||||||
- `rproxy` and `waf` use no token between them. `waf`'s own endpoints, under
|
- `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
|
`/_sww/`, are for your admin tools and your metrics collector, at its own
|
||||||
address on the private network; nothing routes the internet there.
|
address on the private network; nothing routes the internet there.
|
||||||
@@ -676,8 +661,8 @@ stops the running process (see [`docs/state-files.md`](docs/state-files.md)).
|
|||||||
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
||||||
source each.
|
source each.
|
||||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||||
`internal/metrics`: the state and cache files, the log lines, the syslog
|
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||||
sender and the metrics.
|
metrics.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -757,7 +742,6 @@ on the host.
|
|||||||
`http://127.0.0.1:8081`;
|
`http://127.0.0.1:8081`;
|
||||||
- `waf` listens on port 8090;
|
- `waf` listens on port 8090;
|
||||||
- its state files go in `bin/state`;
|
- its state files go in `bin/state`;
|
||||||
- its cache files go in `bin/cache`;
|
|
||||||
- its rule files are those of `share/rules.d`.
|
- its rule files are those of `share/rules.d`.
|
||||||
- `script/example-app`: builds the image and the example app
|
- `script/example-app`: builds the image and the example app
|
||||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||||
@@ -794,8 +778,8 @@ on the host.
|
|||||||
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
||||||
gets.
|
gets.
|
||||||
- [`docs/metrics.md`](docs/metrics.md): the metrics.
|
- [`docs/metrics.md`](docs/metrics.md): the metrics.
|
||||||
- [`docs/state-files.md`](docs/state-files.md): the state and cache files and
|
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||||
what each holds.
|
holds.
|
||||||
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ answers them at its own address, on the private network. "Admin endpoints" in
|
|||||||
[`SPEC.md`](../SPEC.md) says how their tokens work.
|
[`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_sww/metrics`: the metrics, for `SWW_WAF_METRICS_TOKEN` (see
|
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||||
Needs `SWW_WAF_ADMIN_TOKEN`, as do the three below.
|
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||||
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||||
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration
|
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
what is wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- one longer than 4 KiB;
|
- one longer than 4 KiB;
|
||||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||||
@@ -38,7 +38,7 @@ answered `404`, so that the app never sees that prefix (see "`rproxy`" in
|
|||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
|
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
|
||||||
`waf:8080` on its network:
|
`waf:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+5
-6
@@ -23,8 +23,7 @@ back or dropped.
|
|||||||
- the day's AbuseIPDB checks used up.
|
- the day's AbuseIPDB checks used up.
|
||||||
- `file_error`: one of:
|
- `file_error`: one of:
|
||||||
- a rule file or state file edit with an error;
|
- a rule file or state file edit with an error;
|
||||||
- a cache file that does not parse, at start or after an edit;
|
- a state file that cannot be written;
|
||||||
- a state file or cache file that cannot be written;
|
|
||||||
- a replacement lookup database that cannot be read.
|
- a replacement lookup database that cannot be read.
|
||||||
- `summary`: the hourly summary of alerts held back.
|
- `summary`: the hourly summary of alerts held back.
|
||||||
|
|
||||||
@@ -78,8 +77,8 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised.
|
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
`event`: one of the events above.
|
one of the events above.
|
||||||
- `host`: the site of the request that raised the alert, since one `waf` serves
|
- `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`.
|
many sites. It is left out of `source_failure`, `file_error` and `summary`.
|
||||||
- `client`: the address whose request raised the alert. Empty for
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
@@ -122,8 +121,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while
|
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||||
`SWW_WAF_ALERT_NTFY_TOKEN` is set;
|
is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
|
|||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||||
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
|
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
|
|||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
@@ -58,9 +58,9 @@ By `source`: each list's URL, each zone, and `abuseipdb`:
|
|||||||
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
## State and cache files
|
## State files
|
||||||
|
|
||||||
By `file`, the name of a state or cache file:
|
By `file`:
|
||||||
|
|
||||||
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||||
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||||
|
|||||||
+15
-17
@@ -43,7 +43,7 @@ Every one of these lines has:
|
|||||||
|
|
||||||
- `time`: when `waf` had read the question's body, RFC 3339 with milliseconds,
|
- `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.
|
in UTC. The `report` line gives the same time as the `decision` line.
|
||||||
- `instance`: `SWW_WAF_INSTANCE_NAME`.
|
- `instance`: `SWW_INSTANCE_NAME`.
|
||||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||||
- `method`, `path` and `query`: the visitor's request.
|
- `method`, `path` and `query`: the visitor's request.
|
||||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||||
@@ -80,8 +80,8 @@ Every one of these lines has:
|
|||||||
- GeoJS had not answered by the time `waf` answered.
|
- GeoJS had not answered by the time `waf` answered.
|
||||||
- `content_type`: the visitor's `Content-Type`.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `content_length`: the body length the visitor announced.
|
- `content_length`: the body length the visitor announced.
|
||||||
- `request_headers`: the headers listed in `SWW_WAF_LOG_REQUEST_HEADERS`, by
|
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||||
name in lower case.
|
lower case.
|
||||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ Every one of these lines has:
|
|||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `client_denied`: refused for `SWW_WAF_DENY_NETS`, for a reputation source
|
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
||||||
with the action `deny`, or for a tag in `SWW_WAF_TAG_DENY`;
|
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||||
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
||||||
rule, a trap path or a CrowdSec decision that made such a ban;
|
rule, a trap path or a CrowdSec decision that made such a ban;
|
||||||
- `permanently_banned`: refused under a ban that never runs out, including a
|
- `permanently_banned`: refused under a ban that never runs out, including a
|
||||||
@@ -112,23 +112,21 @@ Every one of these lines has:
|
|||||||
is left out when none did.
|
is left out when none did.
|
||||||
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
|
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
|
||||||
heuristic and its figures:
|
heuristic and its figures:
|
||||||
- `deny_nets`: `netblock`, the entry of `SWW_WAF_DENY_NETS` the client is
|
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
|
||||||
in;
|
|
||||||
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
||||||
- `countries`: `country`, and `setting`, the country list that refused it;
|
- `countries`: `country`, and `setting`, the country list that refused it;
|
||||||
- `tag_deny`: `tag`, the tag of `SWW_WAF_TAG_DENY` the request carries;
|
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
|
||||||
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
||||||
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
||||||
decision ends;
|
decision ends;
|
||||||
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
|
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
|
||||||
masked;
|
masked;
|
||||||
- `abuseipdb`: `score`, and `min_score`, `SWW_WAF_ABUSEIPDB_MIN_SCORE`;
|
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
|
||||||
- `rate_limits`: `window`, `count` and `limit`;
|
- `rate_limits`: `window`, `count` and `limit`;
|
||||||
- `trap_paths`: `path`, the trap path asked for;
|
- `trap_paths`: `path`, the trap path asked for;
|
||||||
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
||||||
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
||||||
- `core_rule_set`: `score`, and `threshold`,
|
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
|
|
||||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
||||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||||
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
||||||
@@ -148,7 +146,7 @@ Every one of these lines has:
|
|||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||||
`SWW_WAF_BYTES_COUNT` names, in that window.
|
`SWW_BYTES_COUNT` names, in that window.
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||||
@@ -158,7 +156,7 @@ Every one of these lines has:
|
|||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
|
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
|
||||||
|
|
||||||
When the bytes counted for a request and its response break a limit, the
|
When the bytes counted for a request and its response break a limit, the
|
||||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||||
@@ -176,7 +174,7 @@ Every one of these lines has:
|
|||||||
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||||
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
||||||
present only while `SWW_WAF_ROUTEWATCH_URL` is set:
|
present only while `SWW_ROUTEWATCH_URL` is set:
|
||||||
- `no_answer_yet`: routewatch has not answered about this client yet;
|
- `no_answer_yet`: routewatch has not answered about this client yet;
|
||||||
- `request_failed`: a query to routewatch failed within the last minute, and
|
- `request_failed`: a query to routewatch failed within the last minute, and
|
||||||
the process log says why;
|
the process log says why;
|
||||||
@@ -233,13 +231,13 @@ meanings where they differ:
|
|||||||
was sooner. It includes the wait for `waf`.
|
was sooner. It includes the wait for `waf`.
|
||||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||||
is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
|
is `waf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left out
|
||||||
left out when:
|
when:
|
||||||
- `waf` was not asked, as for the health check;
|
- `waf` was not asked, as for the health check;
|
||||||
- the visitor went away while `waf` was being asked;
|
- the visitor went away while `waf` was being asked;
|
||||||
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
`disposition`, and otherwise left out.
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|||||||
+243
-252
@@ -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
|
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||||
that take `off`, the `_FILE` form, and what each group does and why.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
The settings that start with `SWW_WAF_` are `waf`'s. Those that start with
|
The settings that start with `SWW_` are `waf`'s. Those that start with `SWRP_`
|
||||||
`SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
|
are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses between
|
||||||
chooses between the two (see "The image's command" below).
|
the two (see "The image's command" below).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
@@ -28,99 +28,96 @@ chooses between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port,
|
- `SWW_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port, such
|
||||||
such as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
|
as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||||
name in each line `waf` logs, and in each alert and metric, such as
|
in each line `waf` logs, and in each alert and metric, such as `fsn1/waf`.
|
||||||
`fsn1/waf`. Valid UTF-8. No control character while `SWW_WAF_ALERT_NTFY_URL`
|
Valid UTF-8. No control character while `SWW_ALERT_NTFY_URL` is set. 1 to 48
|
||||||
is set. 1 to 48 printable ASCII characters without a space while
|
printable ASCII characters without a space while `SWW_LOG_REMOTE_URL` is set
|
||||||
`SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME` is not.
|
and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||||
- `SWW_WAF_MODE` (default `enforce`): `enforce` or `observe`.
|
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||||
- `SWW_WAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||||
client's group, from 32 to 128.
|
group, from 32 to 128.
|
||||||
- `SWW_WAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a
|
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||||
whole number above zero.
|
number above zero.
|
||||||
- `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a
|
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a whole
|
||||||
whole number above zero.
|
number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWW_WAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_sww/clients/<ip>`. At least 32 characters.
|
`/_sww/clients/<ip>`. At least 32 characters.
|
||||||
- `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At
|
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||||
least 32 characters.
|
32 characters.
|
||||||
|
|
||||||
## State and cache files
|
## State files
|
||||||
|
|
||||||
- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
|
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||||
files, an absolute path that exists and can be written.
|
absolute path that exists and can be written.
|
||||||
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and
|
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
cache file is written. A duration.
|
written. A duration.
|
||||||
- `SWW_WAF_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache
|
|
||||||
files, an absolute path that can be written. The image's `run` script makes
|
|
||||||
it.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
|
- `SWW_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
|
||||||
that are written: `debug`, `info`, `warn` or `error`.
|
that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWW_WAF_LOG_REQUEST_HEADERS` (default
|
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWW_WAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||||
own. A file that cannot be read, or holds no certificate, is refused.
|
A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||||
wait to be sent, a whole number above zero.
|
to be sent, a whole number above zero.
|
||||||
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name
|
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||||
in each syslog record, 1 to 48 printable ASCII characters without a space.
|
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||||
countries get series of their own. A whole number above zero.
|
get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
monitoring (see step 4 in [`SPEC.md`](../SPEC.md)).
|
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
step 4 in [`SPEC.md`](../SPEC.md)).
|
4 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
The request past one of these limits is refused, and the client is banned.
|
The request past one of these limits is refused, and the client is banned.
|
||||||
|
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
a client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two pages at once with an empty cache, up to 60 files each.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||||
limit.
|
limit.
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
||||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
in a day. A count or `off`. The default is four hours at the hour limit. A
|
||||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||||
limit.
|
limit.
|
||||||
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||||
the runners' addresses in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||||
network where many people share one address, such as an office.
|
network where many people share one address, such as an office.
|
||||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
@@ -130,240 +127,236 @@ counted for a request and its response are added to the client's total once the
|
|||||||
response has ended. The request that takes a client's total past a limit is not
|
response has ended. The request that takes a client's total past a limit is not
|
||||||
cut off; the client is banned.
|
cut off; the client is banned.
|
||||||
|
|
||||||
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
|
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
||||||
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes
|
which bytes count: response bodies, request bodies or both. The request that
|
||||||
count: response bodies, request bodies or both. The request that passes the
|
passes the limit is not cut off, and the client is banned when its response
|
||||||
limit is not cut off, and the client is banned when its response ends.
|
ends.
|
||||||
- `SWW_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte
|
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
||||||
anomaly thresholds count: `response`, the app's response bodies; `request`,
|
thresholds count: `response`, the app's response bodies; `request`, the
|
||||||
the visitors' request bodies; or `both`. What the visitor sent over a
|
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
||||||
WebSocket counts as request bytes, and what the app sent as response bytes.
|
counts as request bytes, and what the app sent as response bytes.
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
stops the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
||||||
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
|
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
|
||||||
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
|
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||||
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
|
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
app the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country list
|
## Country list
|
||||||
|
|
||||||
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
countries whose clients get through, such as `us,de`. A country whose
|
countries whose clients get through, such as `us,de`. A country whose
|
||||||
`country=` tag is in `SWW_WAF_TAG_DENY` stops the start.
|
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
||||||
|
|
||||||
## Request tags
|
## Request tags
|
||||||
|
|
||||||
Every request gets tags from what `waf` knows of its client. See "Request tags"
|
Every request gets tags from what `waf` knows of its client. See "Request tags"
|
||||||
in [`SPEC.md`](../SPEC.md).
|
in [`SPEC.md`](../SPEC.md).
|
||||||
|
|
||||||
- `SWW_WAF_TAG_LISTS` (default empty): address lists that each add a tag, as
|
- `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
|
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||||
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
||||||
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
||||||
So is a URL that is also in `SWW_WAF_BLOCKLIST_URLS`,
|
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||||
`SWW_WAF_ASN_LIMIT_PERCENT_URL`, or the CrowdSec decision list.
|
or the CrowdSec decision list.
|
||||||
- `SWW_WAF_TAG_DENY` (default empty): tags whose requests are refused, such as
|
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||||
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
||||||
`country=` tag without a country, is refused.
|
`country=` tag without a country, is refused.
|
||||||
- `SWW_WAF_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's
|
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||||
limits, as tag and percentage pairs, such as `vpn:50,asn=14061:25`. A
|
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||||
percentage is from `1` to `99`, of every rate limit and byte limit. The lowest
|
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
||||||
percentage among a client's tags applies. A tag listed twice is refused. So is
|
client's tags applies. A tag listed twice is refused. So is
|
||||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
it.
|
it.
|
||||||
|
|
||||||
- `SWW_WAF_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
||||||
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
||||||
service that gives an address's announced netblock and origin AS. `waf` asks
|
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
|
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
|
`prefix=<netblock>` from its answer. While it is unset, nothing asks
|
||||||
routewatch and no request gets a `prefix=` tag.
|
routewatch and no request gets a `prefix=` tag.
|
||||||
|
|
||||||
`SWW_WAF_TAG_DENY` and `SWW_WAF_TAG_LIMIT_PERCENT` accept tags in either case. A
|
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||||
tag that no request can get stops the start. A request can get only these:
|
that no request can get stops the start. A request can get only these:
|
||||||
|
|
||||||
- a name in `SWW_WAF_TAG_LISTS`;
|
- a name in `SWW_TAG_LISTS`;
|
||||||
- an `asn=` or `country=` tag;
|
- an `asn=` or `country=` tag;
|
||||||
- a `prefix=` tag with a netblock, while `SWW_WAF_ROUTEWATCH_URL` is set. It
|
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
|
||||||
matches only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
||||||
- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
|
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||||
masked as the `reputation` field shows it;
|
masked as the `reputation` field shows it;
|
||||||
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
|
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||||
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
|
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
||||||
is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
|
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
|
`off`.
|
||||||
|
|
||||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||||
the request first.
|
the request first.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||||
empty): a percentage of the byte limits alone, for the AS numbers or countries
|
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||||
listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||||
`SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
|
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||||
or country listed twice is refused.
|
country listed twice is refused.
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
the lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
a text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. Each entry counts like an `asn=` tag in
|
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||||
`SWW_WAF_TAG_LIMIT_PERCENT`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is
|
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
refused.
|
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
|
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||||
files.
|
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
|
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
paranoia level, from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
|
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
request is a match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
|
- `SWW_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
|
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
Rule Set does not inspect.
|
not inspect.
|
||||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
|
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||||
request body the Core Rule Set reads, a size of at most `64K`. A question
|
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||||
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
|
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||||
KiB less one byte of it, whatever the value (see "The answer" in
|
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
[`SPEC.md`](../SPEC.md)).
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
|
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||||
Set exclusions, written as Coraza directives. It is loaded before the Core
|
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||||
Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
|
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||||
exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that
|
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||||
cannot be read or does not parse stops the start, whatever
|
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||||
`SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
900000 to 900999 outside a comment, alone or in a range. So does a directive
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
that removes or changes rules as the file loads, such as `SecRuleRemoveById`,
|
yet. It has no `_FILE` form.
|
||||||
since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form.
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
|
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
A duration.
|
duration.
|
||||||
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer is permanent. A duration.
|
||||||
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
of attack. A duration.
|
attack. A duration.
|
||||||
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWW_WAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||||
last try before it is fetched again. A duration of `1h` or more.
|
try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or
|
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
`log`.
|
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
- `SWW_WAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
|
||||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||||
is refused, whatever its case or key.
|
is refused, whatever its case or key.
|
||||||
- `SWW_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones
|
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
optional port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence
|
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
score, from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a
|
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||||
day, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec
|
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
without a user or a fragment; its decision list is at `v1/decisions` under it.
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
Set without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`.
|
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||||
check with AbuseIPDB may take. A duration.
|
with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||||
value. A value cannot hold a comma.
|
value cannot hold a comma.
|
||||||
- `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||||
that receives each alert as a message, checked as the webhook URL is.
|
receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||||
as the webhook URL is.
|
webhook URL is.
|
||||||
- `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWW_WAF_ALERT_EVENTS` (default
|
- `SWW_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWW_WAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
|
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
A whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
Each scope has four thresholds: the most requests and the most body bytes
|
Each scope has four thresholds: the most requests and the most body bytes
|
||||||
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT`
|
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
||||||
names. Requests are counts, bytes are sizes. Each is `off` by default. The four
|
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||||
thresholds per client are:
|
thresholds per client are:
|
||||||
|
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
|
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
|
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||||
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -384,7 +377,7 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||||
-e SWW_COMMAND=waf \
|
-e SWW_COMMAND=waf \
|
||||||
-e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -407,53 +400,51 @@ A few limits are fixed rather than settings:
|
|||||||
|
|
||||||
## `waf`'s answer and `rproxy`'s wait
|
## `waf`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||||
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
|
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||||
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
|
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||||
AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
|
in the background (see "Answering in time" under "`waf`" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`sww rproxy` reads the settings below and no others. They are read once at
|
`sww rproxy` reads the settings below and no others. They are read once at
|
||||||
start, and an invalid value stops the start. Any can be given as a file, as a
|
start, and an invalid value stops the start. None can be given as a file, since
|
||||||
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
|
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||||
|
they do.
|
||||||
|
|
||||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||||
|
|
||||||
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
|
- `SWRP_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or `https`
|
||||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
URL with a host and an optional port, and nothing more. `rproxy` speaks
|
||||||
speaks HTTP/1.1 to it.
|
HTTP/1.1 to it.
|
||||||
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
|
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
|
||||||
answer, to a question and to a report. A time; never `off`.
|
answer, to a question and to a report. A time; never `off`.
|
||||||
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
|
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||||
request when `waf` cannot be connected to, does not answer at all, or does not
|
`waf` cannot be connected to, does not answer at all, or does not answer
|
||||||
answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
|
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||||
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
|
`503`. It does not apply to an answer `waf` gives, such as an error.
|
||||||
error.
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
`off`.
|
||||||
size or `off`.
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
`off`.
|
||||||
size or `off`.
|
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
|
||||||
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
|
send its request line and headers. It then also bounds, from the end of the
|
||||||
take to send its request line and headers. It then also bounds, from the end
|
headers, the time until the app has been sent the whole request. A time or
|
||||||
of the headers, the time until the app has been sent the whole request. A time
|
`off`.
|
||||||
or `off`.
|
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
|
from the end of the request to its last byte reaching the visitor. An answer
|
||||||
take, from the end of the request to its last byte reaching the visitor. An
|
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the
|
||||||
answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
|
status and headers of the app's answer. A time or `off`.
|
||||||
do the status and headers of the app's answer. A time or `off`.
|
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||||
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||||
`:9000`.
|
`https` URL with a host and an optional port, and nothing more.
|
||||||
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
|
||||||
`http` or `https` URL with a host and an optional port, and nothing more.
|
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
|
||||||
- `SWW_RPROXY_TRUSTED_PROXIES` (default
|
nothing. A client outside these netblocks cannot claim another address. Where
|
||||||
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
|
other containers reach `rproxy` directly, or visitors on a private network
|
||||||
`X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
|
reach traefik, they can name another address in the header. Narrowing the
|
||||||
outside these netblocks cannot claim another address. Where other containers
|
setting to traefik's own network closes that gap.
|
||||||
reach `rproxy` directly, or visitors on a private network reach traefik, they
|
|
||||||
can name another address in the header. Narrowing the setting to traefik's own
|
|
||||||
network closes that gap.
|
|
||||||
|
|
||||||
## The image's command
|
## The image's command
|
||||||
|
|
||||||
@@ -465,14 +456,14 @@ It cannot be given as a file.
|
|||||||
|
|
||||||
Nothing reads these names any more:
|
Nothing reads these names any more:
|
||||||
|
|
||||||
- `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`.
|
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||||
- `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`.
|
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||||
- `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`.
|
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||||
- `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`.
|
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||||
- `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are
|
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||||
`SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds.
|
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||||
- `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||||
`SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||||
- `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES`
|
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||||
have no setting. `rproxy` closes a kept-open connection idle for 120 seconds,
|
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||||
and takes a request line and headers of at most 32 KiB.
|
takes a request line and headers of at most 32 KiB.
|
||||||
+23
-78
@@ -1,28 +1,13 @@
|
|||||||
# State and cache files
|
# State files
|
||||||
|
|
||||||
The JSON files `waf` keeps, and what each holds. "Persistent state" in
|
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||||
[`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has a
|
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||||
top-level `version` of 1.
|
a top-level `version` of 1.
|
||||||
|
|
||||||
- The state files are in `SWW_WAF_STATE_DIR`, `/var/lib/sww` by default. They
|
|
||||||
hold what cannot be fetched again, so back them up:
|
|
||||||
- `bans.json`;
|
|
||||||
- `clients.json`;
|
|
||||||
- `abuseipdb-checks-spent.json`;
|
|
||||||
- `alerts.json`.
|
|
||||||
- The cache files are in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default. They
|
|
||||||
hold answers from outside services, which are asked again when a file is lost,
|
|
||||||
so the directory can be left out of backups:
|
|
||||||
- `lookups.json`;
|
|
||||||
- `lists.json`;
|
|
||||||
- `dnsbl.json`;
|
|
||||||
- `abuseipdb-scores.json`;
|
|
||||||
- `routewatch.json`.
|
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of
|
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||||
those `waf` made. Per entry:
|
`waf` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -82,13 +67,21 @@ since it was first seen:
|
|||||||
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
|
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
|
||||||
kept, and which nothing adds to.
|
kept, and which nothing adds to.
|
||||||
|
|
||||||
## `abuseipdb-checks-spent.json`
|
## `lookups.json`
|
||||||
|
|
||||||
The AbuseIPDB checks spent today, so that a restart does not spend the daily
|
GeoJS answers, one per line:
|
||||||
budget again:
|
|
||||||
|
|
||||||
- `day`: the day, in UTC;
|
- `client`;
|
||||||
- `spent`: the checks spent on it.
|
- `asn`, `as_name` and `country`;
|
||||||
|
- `answered`: when GeoJS answered;
|
||||||
|
- `used`: when the answer was last used.
|
||||||
|
|
||||||
|
## `reputation.json`
|
||||||
|
|
||||||
|
- the last good copy of each list fetched from a URL, with when it was last
|
||||||
|
tried;
|
||||||
|
- each DNS blocklist verdict still in use;
|
||||||
|
- AbuseIPDB's scores, and the checks spent today.
|
||||||
|
|
||||||
## `alerts.json`
|
## `alerts.json`
|
||||||
|
|
||||||
@@ -98,63 +91,15 @@ budget again:
|
|||||||
- the alerts sent and held back in the current hour;
|
- the alerts sent and held back in the current hour;
|
||||||
- the alerts waiting for each destination.
|
- the alerts waiting for each destination.
|
||||||
|
|
||||||
## `lookups.json`
|
|
||||||
|
|
||||||
A cache file. GeoJS answers, one per line:
|
|
||||||
|
|
||||||
- `client`;
|
|
||||||
- `asn`, `as_name` and `country`;
|
|
||||||
- `answered`: when GeoJS answered;
|
|
||||||
- `used`: when the answer was last used.
|
|
||||||
|
|
||||||
## `lists.json`
|
|
||||||
|
|
||||||
A cache file. The last good copy of each list fetched from a URL, with when it
|
|
||||||
was last tried:
|
|
||||||
|
|
||||||
- `url`;
|
|
||||||
- `tried`: when it was last fetched or tried;
|
|
||||||
- `fetched` and `lines`: when the last good copy was fetched, and its lines,
|
|
||||||
comment lines included.
|
|
||||||
|
|
||||||
## `dnsbl.json`
|
|
||||||
|
|
||||||
A cache file. Each DNS blocklist verdict still in use, one per line:
|
|
||||||
|
|
||||||
- `zone` and `client`;
|
|
||||||
- `listed`: whether the zone lists the client;
|
|
||||||
- `fetched`: when the zone answered.
|
|
||||||
|
|
||||||
## `abuseipdb-scores.json`
|
|
||||||
|
|
||||||
A cache file. AbuseIPDB's scores still in use, one per line:
|
|
||||||
|
|
||||||
- `client`;
|
|
||||||
- `score`: from 0 to 100;
|
|
||||||
- `fetched`: when AbuseIPDB answered.
|
|
||||||
|
|
||||||
## `routewatch.json`
|
|
||||||
|
|
||||||
A cache file. routewatch's answers from the last hour, one per line:
|
|
||||||
|
|
||||||
- `client`;
|
|
||||||
- `netblock`: the netblock the client's address is announced in, empty for an
|
|
||||||
address without a live route;
|
|
||||||
- `asn`: the number of the AS that originates the netblock, 0 for none;
|
|
||||||
- `answered`: when routewatch answered.
|
|
||||||
|
|
||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB
|
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
of disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWW_WAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
full.
|
full.
|
||||||
- `routewatch.json`: about 120 bytes per answer, at most about 12 MiB.
|
- `reputation.json` and `alerts.json`: usually a few MiB or less.
|
||||||
- `abuseipdb-checks-spent.json`: a few bytes.
|
|
||||||
- `lists.json`, `dnsbl.json`, `abuseipdb-scores.json` and `alerts.json`: usually
|
|
||||||
a few MiB or less.
|
|
||||||
@@ -11,14 +11,12 @@ require (
|
|||||||
github.com/maxmind/mmdbwriter v1.2.0
|
github.com/maxmind/mmdbwriter v1.2.0
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.7.0
|
github.com/oschwald/maxminddb-golang/v2 v2.7.0
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/spf13/viper v1.21.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/corazawaf/libinjection-go v0.3.4 // indirect
|
github.com/corazawaf/libinjection-go v0.3.4 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
||||||
@@ -28,22 +26,14 @@ require (
|
|||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/magefile/mage v1.17.0 // indirect
|
github.com/magefile/mage v1.17.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/net v0.60.0 // indirect
|
golang.org/x/net v0.60.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
|
|||||||
@@ -16,12 +16,8 @@ github.com/corazawaf/libinjection-go v0.3.4 h1:jX2Tit2xZxYJJyVryNXucYBZtQqS7473x
|
|||||||
github.com/corazawaf/libinjection-go v0.3.4/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
github.com/corazawaf/libinjection-go v0.3.4/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||||
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
||||||
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
@@ -42,10 +38,6 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs
|
|||||||
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
||||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||||
@@ -70,24 +62,8 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
|
|||||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
|||||||
+33
-35
@@ -1,12 +1,12 @@
|
|||||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
// threshold, on a source that fails and on a file with an error to each
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
// destination set: to the webhook
|
// destination set: to the webhook
|
||||||
// SWW_WAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// object" in docs/alerts.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWW_WAF_ALERT_EVENTS names them.
|
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban sww made.
|
// EventBan is a ban sww made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
@@ -45,7 +45,7 @@ const (
|
|||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
@@ -56,21 +56,19 @@ const (
|
|||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is one of:
|
// EventFileError is a rule file or state file edited while sww
|
||||||
// - a rule file or state file edited while sww runs that does not parse;
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
// - a cache file that does not parse, at start or after an edit;
|
// cannot be read, or a state file that cannot be written.
|
||||||
// - a state file or cache file that cannot be written;
|
|
||||||
// - a replacement of the lookup database that cannot be read.
|
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWW_WAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWW_WAF_ALERT_EVENTS does not name it.
|
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its
|
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -82,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWW_WAF_ALERT_SLACK_WEBHOOK_URL names.
|
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWW_WAF_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWW_WAF_INSTANCE_NAME, which every alert gives.
|
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -303,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWW_WAF_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -365,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWW_WAF_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -610,13 +608,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWW_WAF_ALERT_NTFY_URL": onlyNtfy,
|
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWW_WAF_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWW_WAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWW_WAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "hooks.slack.example") ||
|
strings.Contains(logged, "hooks.slack.example") ||
|
||||||
strings.Contains(logged, "T0123") {
|
strings.Contains(logged, "T0123") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWW_WAF_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
|
|||||||
+12
-12
@@ -28,16 +28,16 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWW_WAF_IPV6_GROUP_PREFIX.
|
// SWW_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWW_WAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
|
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
|
||||||
ScopeWatchedNet = "watched_net"
|
ScopeWatchedNet = "watched_net"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||||
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
|
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||||
// SWW_WAF_WATCH_*.
|
// SWW_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
|
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWW_WAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -107,13 +107,13 @@ type Request struct {
|
|||||||
Host string
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWW_WAF_IPV6_GROUP_PREFIX its address is in.
|
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ type Notes struct {
|
|||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||||
// one for a request for a path in SWW_WAF_TRAP_PATHS, that path as the
|
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -199,7 +199,7 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWW_WAF_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
|
|||||||
+278
-294
@@ -1,9 +1,10 @@
|
|||||||
// Package config reads sww's settings, and is the one place they are
|
// Package config reads sww's settings, and is the one place they are
|
||||||
// read. Every setting is an environment variable. The settings of `sww
|
// read. Every setting is an environment variable. The settings of `sww
|
||||||
// waf` start with SWW_WAF_, 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 SWW_RPROXY_. Each
|
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each
|
||||||
// of them but SWW_RPROXY_WAF_BACKEND has a default, and each can be given as a file
|
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
|
||||||
// too. SWW_COMMAND chooses which of the two the image runs.
|
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
|
||||||
|
// the image runs.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,8 +27,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
@@ -36,121 +35,121 @@ import (
|
|||||||
// Config is the settings of `sww waf`. A time or size limit of
|
// Config is the settings of `sww waf`. A time or size limit of
|
||||||
// zero is off.
|
// zero is off.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
|
// ListenAddr is where waf listens (SWW_LISTEN_ADDR).
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// InstanceName is the name every log line and alert gives as instance,
|
// InstanceName is the name every log line and alert gives as instance,
|
||||||
// and every metric carries as its label instance (SWW_WAF_INSTANCE_NAME),
|
// and every metric carries as its label instance (SWW_INSTANCE_NAME),
|
||||||
// by default the host's name, which docker sets to the first 12
|
// by default the host's name, which docker sets to the first 12
|
||||||
// characters of the container's id.
|
// characters of the container's id.
|
||||||
InstanceName string
|
InstanceName string
|
||||||
// Observe is true in observe mode, when SWW_WAF_MODE is observe rather
|
// Observe is true in observe mode, when SWW_MODE is observe rather
|
||||||
// than enforce: a request that SWW_WAF_DENY_NETS, a ban, the country
|
// than enforce: a request that SWW_DENY_NETS, a ban, the country
|
||||||
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
||||||
// allowed instead, and no ban is made.
|
// allowed instead, and no ban is made.
|
||||||
Observe bool
|
Observe bool
|
||||||
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
||||||
// (SWW_WAF_IPV6_GROUP_PREFIX), from 32 to 128.
|
// (SWW_IPV6_GROUP_PREFIX), from 32 to 128.
|
||||||
IPv6GroupPrefix int
|
IPv6GroupPrefix int
|
||||||
// MaxTrackedClients is the most clients the table of clients holds, in
|
// MaxTrackedClients is the most clients the table of clients holds, in
|
||||||
// memory and in clients.json (SWW_WAF_MAX_TRACKED_CLIENTS).
|
// memory and in clients.json (SWW_MAX_TRACKED_CLIENTS).
|
||||||
MaxTrackedClients int
|
MaxTrackedClients int
|
||||||
// AllowNets are the netblocks whose clients skip every check
|
// AllowNets are the netblocks whose clients skip every check
|
||||||
// (SWW_WAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
|
// (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the
|
||||||
// rate limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_NETS).
|
// rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS).
|
||||||
// DenyNets are those whose clients are always refused
|
// DenyNets are those whose clients are always refused
|
||||||
// (SWW_WAF_DENY_NETS).
|
// (SWW_DENY_NETS).
|
||||||
AllowNets []netip.Prefix
|
AllowNets []netip.Prefix
|
||||||
RateLimitExemptNets []netip.Prefix
|
RateLimitExemptNets []netip.Prefix
|
||||||
DenyNets []netip.Prefix
|
DenyNets []netip.Prefix
|
||||||
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
||||||
// most requests a client may make in a minute, an hour and a day
|
// most requests a client may make in a minute, an hour and a day
|
||||||
// (SWW_WAF_RATE_LIMIT_PER_MINUTE, SWW_WAF_RATE_LIMIT_PER_HOUR and
|
// (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and
|
||||||
// SWW_WAF_RATE_LIMIT_PER_DAY).
|
// SWW_RATE_LIMIT_PER_DAY).
|
||||||
RateLimitPerMinute int64
|
RateLimitPerMinute int64
|
||||||
RateLimitPerHour int64
|
RateLimitPerHour int64
|
||||||
RateLimitPerDay int64
|
RateLimitPerDay int64
|
||||||
// RateLimitExemptPaths are the path prefixes whose requests the rate
|
// RateLimitExemptPaths are the path prefixes whose requests the rate
|
||||||
// limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_PATHS).
|
// limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS).
|
||||||
// Each starts with /.
|
// Each starts with /.
|
||||||
RateLimitExemptPaths []string
|
RateLimitExemptPaths []string
|
||||||
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
||||||
// most bytes a client's requests may carry in a minute, an hour and a
|
// most bytes a client's requests may carry in a minute, an hour and a
|
||||||
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
|
// day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and
|
||||||
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
// SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
||||||
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
|
// toward them (SWW_BYTES_COUNT): response, request or both.
|
||||||
BytesLimitPerMinute int64
|
BytesLimitPerMinute int64
|
||||||
BytesLimitPerHour int64
|
BytesLimitPerHour int64
|
||||||
BytesLimitPerDay int64
|
BytesLimitPerDay int64
|
||||||
BytesCount string
|
BytesCount string
|
||||||
// LookupSource is where each client's AS number and country are
|
// LookupSource is where each client's AS number and country are
|
||||||
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
// looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
||||||
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
// in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any
|
||||||
// other source. A request waits up to LookupTimeout for its client's
|
// other source. A request waits up to LookupTimeout for its client's
|
||||||
// first answer from GeoJS while a setting needs it
|
// first answer from GeoJS while a setting needs it
|
||||||
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
// (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
||||||
// when the app is passed the client's AS number and country in headers
|
// when the app is passed the client's AS number and country in headers
|
||||||
// (SWW_WAF_ADD_LOOKUP_HEADERS).
|
// (SWW_ADD_LOOKUP_HEADERS).
|
||||||
LookupSource string
|
LookupSource string
|
||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
LookupTimeout time.Duration
|
LookupTimeout time.Duration
|
||||||
AddLookupHeaders bool
|
AddLookupHeaders bool
|
||||||
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
||||||
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
||||||
// two-letter codes in capitals, as GeoJS gives them.
|
// two-letter codes in capitals, as GeoJS gives them.
|
||||||
ExclusivelyAllowedCountries []string
|
ExclusivelyAllowedCountries []string
|
||||||
// TagLists are the address lists that each add a tag to the requests of
|
// TagLists are the address lists that each add a tag to the requests of
|
||||||
// the clients they hold (SWW_WAF_TAG_LISTS). TagDeny are the tags whose
|
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
|
||||||
// requests are refused (SWW_WAF_TAG_DENY). TagLimitPercent gives the clients
|
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
|
||||||
// with a tag that percentage of every rate limit and byte limit, from 1
|
// with a tag that percentage of every rate limit and byte limit, from 1
|
||||||
// to 99 (SWW_WAF_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
|
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
|
||||||
// lower case, a country code in capitals.
|
// lower case, a country code in capitals.
|
||||||
TagLists []TagList
|
TagLists []TagList
|
||||||
TagDeny []string
|
TagDeny []string
|
||||||
TagLimitPercent map[string]int64
|
TagLimitPercent map[string]int64
|
||||||
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
||||||
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
|
// and prefix= tags, "" for none (SWW_ROUTEWATCH_URL).
|
||||||
RouteWatchURL string
|
RouteWatchURL string
|
||||||
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
||||||
// those they list a percentage of the byte limits in place of the one
|
// those they list a percentage of the byte limits in place of the one
|
||||||
// TagLimitPercent gives their asn= or country= tag
|
// TagLimitPercent gives their asn= or country= tag
|
||||||
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
|
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
||||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
||||||
// country, a two-letter code in capitals, as the lookup gives them.
|
// country, a two-letter code in capitals, as the lookup gives them.
|
||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// UnknownLimitPercent is the percentage of every limit a client without
|
||||||
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
|
// count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL),
|
||||||
// "" while it is unset.
|
// "" while it is unset.
|
||||||
ASNBytesPercent map[string]int64
|
ASNBytesPercent map[string]int64
|
||||||
CountryBytesPercent map[string]int64
|
CountryBytesPercent map[string]int64
|
||||||
UnknownLimitPercent int64
|
UnknownLimitPercent int64
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// BlocklistURLs are where the blocklists are fetched from
|
// BlocklistURLs are where the blocklists are fetched from
|
||||||
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
// (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
||||||
// fetched again BlocklistRefresh after it was last fetched or tried
|
// fetched again BlocklistRefresh after it was last fetched or tried
|
||||||
// (SWW_WAF_BLOCKLIST_REFRESH), which is never less than an hour.
|
// (SWW_BLOCKLIST_REFRESH), which is never less than an hour.
|
||||||
// BlocklistAction is what is done with a client a blocklist lists
|
// BlocklistAction is what is done with a client a blocklist lists
|
||||||
// (SWW_WAF_BLOCKLIST_ACTION): deny, limit or log; for limit,
|
// (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit,
|
||||||
// BlocklistLimitPercent is the percentage of every limit it gets.
|
// BlocklistLimitPercent is the percentage of every limit it gets.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
BlocklistRefresh time.Duration
|
BlocklistRefresh time.Duration
|
||||||
BlocklistAction string
|
BlocklistAction string
|
||||||
BlocklistLimitPercent int64
|
BlocklistLimitPercent int64
|
||||||
// DNSBLZones are the DNSBL zones clients are asked about
|
// DNSBLZones are the DNSBL zones clients are asked about
|
||||||
// (SWW_WAF_DNSBL_ZONES), through DNSBLResolver (SWW_WAF_DNSBL_RESOLVER), or
|
// (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or
|
||||||
// the host's resolver while that is the zero AddrPort.
|
// the host's resolver while that is the zero AddrPort.
|
||||||
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
||||||
// with (SWW_WAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
// with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
||||||
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
|
// of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and
|
||||||
// at most AbuseIPDBDailyBudget checks are made a day
|
// at most AbuseIPDBDailyBudget checks are made a day
|
||||||
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||||
// ReputationAction is what is done with a client a zone's verdict lists,
|
// ReputationAction is what is done with a client a zone's verdict lists,
|
||||||
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
|
// or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log;
|
||||||
// for limit, ReputationLimitPercent is the percentage of every limit it
|
// for limit, ReputationLimitPercent is the percentage of every limit it
|
||||||
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
||||||
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
// fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take
|
||||||
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
|
// ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off.
|
||||||
DNSBLZones []string
|
DNSBLZones []string
|
||||||
DNSBLResolver netip.AddrPort
|
DNSBLResolver netip.AddrPort
|
||||||
AbuseIPDBKey string
|
AbuseIPDBKey string
|
||||||
@@ -161,77 +160,74 @@ type Config struct {
|
|||||||
ReputationCacheTTL time.Duration
|
ReputationCacheTTL time.Duration
|
||||||
ReputationTimeout time.Duration
|
ReputationTimeout time.Duration
|
||||||
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
||||||
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
|
// engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from:
|
||||||
// that URL with v1/decisions added to its path, "" while it is unset and
|
// that URL with v1/decisions added to its path, "" while it is unset and
|
||||||
// none is. CrowdSecKey is the key the engine is asked with
|
// none is. CrowdSecKey is the key the engine is asked with
|
||||||
// (SWW_WAF_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWW_WAF_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
// breaks a rate limit or matches a ban rule, SWW_WAF_DENY_NETS,
|
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
|
||||||
// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_WAF_TAG_DENY.
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
|
||||||
BanResponse int
|
BanResponse int
|
||||||
// LimitBanDuration is the ban for a first broken rate limit
|
// LimitBanDuration is the ban for a first broken rate limit
|
||||||
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
// LimitBanRepeatWindow after the last ban ended bans for three times
|
// LimitBanRepeatWindow after the last ban ended bans for three times
|
||||||
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
// as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
||||||
// would be longer than MaxBanDuration is permanent instead
|
// would be longer than MaxBanDuration is permanent instead
|
||||||
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
|
// (SWW_MAX_BAN_DURATION). None of them can be off.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
MaxBanDuration time.Duration
|
MaxBanDuration time.Duration
|
||||||
// AttackBanDuration is the ban for a first clear sign of attack
|
// AttackBanDuration is the ban for a first clear sign of attack
|
||||||
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
|
// (SWW_ATTACK_BAN_DURATION). It cannot be off.
|
||||||
AttackBanDuration time.Duration
|
AttackBanDuration time.Duration
|
||||||
// MaxBans is the most bans held (SWW_WAF_MAX_BANS).
|
// MaxBans is the most bans held (SWW_MAX_BANS).
|
||||||
MaxBans int
|
MaxBans int
|
||||||
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
||||||
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
|
// client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX).
|
||||||
BanScopeV4Prefix int
|
BanScopeV4Prefix int
|
||||||
// StateDir is the directory of the state files, an absolute path
|
// StateDir is the directory of the state files, an absolute path
|
||||||
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
// (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
||||||
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
|
// is made (SWW_STATE_WRITE_DELAY), and every state file every
|
||||||
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
|
// StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be
|
||||||
// can be off.
|
// off.
|
||||||
StateDir string
|
StateDir string
|
||||||
StateWriteDelay time.Duration
|
StateWriteDelay time.Duration
|
||||||
StateCounterInterval time.Duration
|
StateCounterInterval time.Duration
|
||||||
// CacheDir is the directory of the cache files, which keep answers
|
|
||||||
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
|
|
||||||
CacheDir string
|
|
||||||
// LogRequestHeaders are the request headers whose values the request
|
// LogRequestHeaders are the request headers whose values the request
|
||||||
// log gives, in lower case (SWW_WAF_LOG_REQUEST_HEADERS).
|
// log gives, in lower case (SWW_LOG_REQUEST_HEADERS).
|
||||||
LogRequestHeaders []string
|
LogRequestHeaders []string
|
||||||
// LogLevel is the least severe of the process's own messages that are
|
// LogLevel is the least severe of the process's own messages that are
|
||||||
// written (SWW_WAF_LOG_LEVEL). It holds back no request log line.
|
// written (SWW_LOG_LEVEL). It holds back no request log line.
|
||||||
LogLevel slog.Level
|
LogLevel slog.Level
|
||||||
// AdminToken is the bearer token an admin sends for the ban endpoints
|
// AdminToken is the bearer token an admin sends for the ban endpoints
|
||||||
// and /_sww/clients/<ip> (SWW_WAF_ADMIN_TOKEN), "" while it is
|
// and /_sww/clients/<ip> (SWW_ADMIN_TOKEN), "" while it is
|
||||||
// unset and they are off.
|
// unset and they are off.
|
||||||
AdminToken string
|
AdminToken string
|
||||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||||
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
// (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||||
// MetricsTopN is how many AS numbers and how many countries get series
|
// MetricsTopN is how many AS numbers and how many countries get series
|
||||||
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
|
// of their own in the metrics (SWW_METRICS_TOP_N).
|
||||||
MetricsToken string
|
MetricsToken string
|
||||||
MetricsTopN int
|
MetricsTopN int
|
||||||
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
|
// RulesDir is the directory of the rule files (SWW_RULES_DIR), read
|
||||||
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
|
// unless RulesEnabled is false (SWW_RULES_ENABLED).
|
||||||
RulesDir string
|
RulesDir string
|
||||||
RulesEnabled bool
|
RulesEnabled bool
|
||||||
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
|
// WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff,
|
||||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
||||||
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
|
// level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and
|
||||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
||||||
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
// (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
||||||
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
|
// are the ids of its rules switched off (SWW_WAF_DISABLED_RULES),
|
||||||
// WAFExemptPaths the path prefixes it does not inspect
|
// WAFExemptPaths the path prefixes it does not inspect
|
||||||
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
// (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
||||||
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
// it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||||
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
||||||
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
// (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
||||||
WAFMode string
|
WAFMode string
|
||||||
WAFParanoiaLevel int
|
WAFParanoiaLevel int
|
||||||
WAFAnomalyThreshold int
|
WAFAnomalyThreshold int
|
||||||
@@ -240,21 +236,21 @@ type Config struct {
|
|||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||||
// that waf 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_WAF_ERROR_BURST_THRESHOLD). 0 is off.
|
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
|
||||||
ErrorBurstThreshold int64
|
ErrorBurstThreshold int64
|
||||||
// LogRemoteURL is where every line on stdout is also sent
|
// LogRemoteURL is where every line on stdout is also sent
|
||||||
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
// (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||||
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
// certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while
|
||||||
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
||||||
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
|
// lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER).
|
||||||
// LogRemoteFacility is the number of the syslog facility
|
// LogRemoteFacility is the number of the syslog facility
|
||||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
// (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
||||||
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
// (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
||||||
// the lines are sent in.
|
// the lines are sent in.
|
||||||
LogRemoteURL *url.URL
|
LogRemoteURL *url.URL
|
||||||
LogRemoteTLSCAs *x509.CertPool
|
LogRemoteTLSCAs *x509.CertPool
|
||||||
@@ -262,18 +258,18 @@ type Config struct {
|
|||||||
LogRemoteFacility int
|
LogRemoteFacility int
|
||||||
LogRemoteAppName string
|
LogRemoteAppName string
|
||||||
// AlertWebhookURL is where each alert is posted as JSON
|
// AlertWebhookURL is where each alert is posted as JSON
|
||||||
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
||||||
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
|
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
|
||||||
// posted to as a message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), and
|
// posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and
|
||||||
// AlertNtfyURL the ntfy topic each is published to
|
// AlertNtfyURL the ntfy topic each is published to
|
||||||
// (SWW_WAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
|
// (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
|
||||||
// unless empty, is sent to ntfy with each (SWW_WAF_ALERT_NTFY_TOKEN).
|
// unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN).
|
||||||
// With none of the three URLs set, no alert is sent. AlertEvents are
|
// With none of the three URLs set, no alert is sent. AlertEvents are
|
||||||
// the events alerts are sent for (SWW_WAF_ALERT_EVENTS). A repeat of an
|
// the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an
|
||||||
// alert within AlertCooldown is held back (SWW_WAF_ALERT_COOLDOWN), and
|
// alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and
|
||||||
// so is an alert past AlertMaxPerHour in an hour, for the hour's
|
// so is an alert past AlertMaxPerHour in an hour, for the hour's
|
||||||
// summary (SWW_WAF_ALERT_MAX_PER_HOUR); 0 is off for both.
|
// summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both.
|
||||||
AlertWebhookURL *url.URL
|
AlertWebhookURL *url.URL
|
||||||
AlertWebhookHeaders http.Header
|
AlertWebhookHeaders http.Header
|
||||||
AlertSlackWebhookURL *url.URL
|
AlertSlackWebhookURL *url.URL
|
||||||
@@ -283,14 +279,14 @@ type Config struct {
|
|||||||
AlertCooldown time.Duration
|
AlertCooldown time.Duration
|
||||||
AlertMaxPerHour int
|
AlertMaxPerHour int
|
||||||
// The anomaly thresholds, which only raise alerts: the most requests
|
// The anomaly thresholds, which only raise alerts: the most requests
|
||||||
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
|
// and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*),
|
||||||
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
|
// per netblock around a client (SWW_ANOMALY_NET_*), per AS number
|
||||||
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
|
// (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*)
|
||||||
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
|
// and per named netblock (SWW_WATCH_*), each 0 while it is off.
|
||||||
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
||||||
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
|
// netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and
|
||||||
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
// SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
||||||
// (SWW_WAF_WATCH_NETS).
|
// (SWW_WATCH_NETS).
|
||||||
AnomalyClient anomaly.Thresholds
|
AnomalyClient anomaly.Thresholds
|
||||||
AnomalyNet anomaly.Thresholds
|
AnomalyNet anomaly.Thresholds
|
||||||
AnomalyASN anomaly.Thresholds
|
AnomalyASN anomaly.Thresholds
|
||||||
@@ -309,7 +305,7 @@ type Config struct {
|
|||||||
// off.
|
// off.
|
||||||
const off = "off"
|
const off = "off"
|
||||||
|
|
||||||
// The values of SWW_WAF_CORE_RULE_SET_MODE.
|
// The values of SWW_WAF_MODE.
|
||||||
const (
|
const (
|
||||||
// WAFModeOff runs no request through the Core Rule Set.
|
// WAFModeOff runs no request through the Core Rule Set.
|
||||||
WAFModeOff = off
|
WAFModeOff = off
|
||||||
@@ -319,8 +315,8 @@ const (
|
|||||||
WAFModeBlock = "block"
|
WAFModeBlock = "block"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database, the file SWW_WAF_LOOKUP_DB_PATH names.
|
// lookup database, the file SWW_LOOKUP_DB_PATH names.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -330,7 +326,7 @@ const (
|
|||||||
gibibyte = 1 << 30
|
gibibyte = 1 << 30
|
||||||
ipv4Bits = 32
|
ipv4Bits = 32
|
||||||
ipv6Bits = 128
|
ipv6Bits = 128
|
||||||
// minIPv6GroupPrefix is the shortest SWW_WAF_IPV6_GROUP_PREFIX, the
|
// minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the
|
||||||
// netblock a provider is usually given: a shorter one would make one
|
// netblock a provider is usually given: a shorter one would make one
|
||||||
// client of the customers of several providers.
|
// client of the customers of several providers.
|
||||||
minIPv6GroupPrefix = 32
|
minIPv6GroupPrefix = 32
|
||||||
@@ -347,8 +343,8 @@ const (
|
|||||||
// masked is what the log shows for a token that is set, and in place of
|
// masked is what the log shows for a token that is set, and in place of
|
||||||
// a secret in another setting.
|
// a secret in another setting.
|
||||||
masked = "********"
|
masked = "********"
|
||||||
// defaultListenAddr is the default of SWW_WAF_LISTEN_ADDR and
|
// defaultListenAddr is the default of SWW_LISTEN_ADDR and
|
||||||
// SWW_RPROXY_LISTEN_ADDR, and defaultUpstreamURL that of SWW_RPROXY_UPSTREAM_URL.
|
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
|
||||||
defaultListenAddr = ":8080"
|
defaultListenAddr = ":8080"
|
||||||
defaultUpstreamURL = "http://127.0.0.1:8081"
|
defaultUpstreamURL = "http://127.0.0.1:8081"
|
||||||
)
|
)
|
||||||
@@ -376,7 +372,7 @@ var (
|
|||||||
errHeaderTakenOut = errors.New(
|
errHeaderTakenOut = errors.New(
|
||||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||||
"be logged")
|
"be logged")
|
||||||
errOnBothLists = errors.New("is in SWW_WAF_TAG_DENY too")
|
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
|
||||||
errNotLookupSource = errors.New("is not geojs, file or off")
|
errNotLookupSource = errors.New("is not geojs, file or off")
|
||||||
errNeedsLookups = errors.New("it needs each client looked up")
|
errNeedsLookups = errors.New("it needs each client looked up")
|
||||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||||
@@ -452,7 +448,7 @@ var (
|
|||||||
errNotListURL = errors.New(
|
errNotListURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as https://www.spamhaus.org/drop/drop.txt")
|
"such as https://www.spamhaus.org/drop/drop.txt")
|
||||||
errInBlocklistURLs = errors.New("is in SWW_WAF_BLOCKLIST_URLS too")
|
errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too")
|
||||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||||
errNotAction = errors.New(
|
errNotAction = errors.New(
|
||||||
"is not deny, limit:<percent> such as limit:25, or log")
|
"is not deny, limit:<percent> such as limit:25, or log")
|
||||||
@@ -476,9 +472,9 @@ var (
|
|||||||
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||||
errAnotherList = errors.New(
|
errAnotherList = errors.New(
|
||||||
"is in SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too")
|
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
||||||
errFetchedOtherwise = errors.New("is in SWW_WAF_BLOCKLIST_URLS, is " +
|
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
|
||||||
"SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
|
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
|
||||||
errNotTagList = errors.New(
|
errNotTagList = errors.New(
|
||||||
"is not a name, = and an http or https URL, such as " +
|
"is not a name, = and an http or https URL, such as " +
|
||||||
"torexit=https://example.com/tor.txt")
|
"torexit=https://example.com/tor.txt")
|
||||||
@@ -494,124 +490,124 @@ var (
|
|||||||
errCrowdSecTag = errors.New(
|
errCrowdSecTag = errors.New(
|
||||||
"cannot be used: a CrowdSec decision refuses the request first")
|
"cannot be used: a CrowdSec decision refuses the request first")
|
||||||
errNoRequestGetsTag = errors.New(
|
errNoRequestGetsTag = errors.New(
|
||||||
"is a tag no request can get: it is not a name in SWW_WAF_TAG_LISTS, an " +
|
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
|
||||||
"asn= or country= tag, a dnsbl= tag of a zone in SWW_WAF_DNSBL_ZONES, " +
|
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
|
||||||
"a prefix= tag with SWW_WAF_ROUTEWATCH_URL set, abuseipdb with " +
|
"a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " +
|
||||||
"SWW_WAF_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
|
"SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
|
||||||
"Core Rule Set on")
|
"Core Rule Set on")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings through v, normally the viper
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
// NewViper returns, bound to the process's environment. A setting may
|
// os.LookupEnv. A setting may instead be given as a file: the variable
|
||||||
// instead be given as a file: the variable named by the setting's name
|
// named by the setting's name with _FILE added names the file, which is
|
||||||
// with _FILE added names the file, which is read now (see lookup). A
|
// read now (see lookup). A setting that is not set takes its default. A
|
||||||
// setting that is not set takes its default. A setting that is set but
|
// setting that is set but invalid is an error that names it.
|
||||||
// invalid is an error that names it.
|
|
||||||
//
|
//
|
||||||
//nolint:funlen // one line for each setting, a list that grows with them
|
//nolint:funlen // one line for each setting, a list that grows with them
|
||||||
func FromEnvironment(v *viper.Viper) (*Config, error) {
|
func FromEnvironment(
|
||||||
env := &environment{v: v}
|
lookupEnv func(string) (string, bool),
|
||||||
|
) (*Config, error) {
|
||||||
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
||||||
InstanceName: env.instanceName(),
|
InstanceName: env.instanceName(),
|
||||||
Observe: env.observe("SWW_WAF_MODE", "enforce"),
|
Observe: env.observe("SWW_MODE", "enforce"),
|
||||||
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_WAF_IPV6_GROUP_PREFIX", "64"),
|
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"),
|
||||||
MaxTrackedClients: env.numberNotOff("SWW_WAF_MAX_TRACKED_CLIENTS", "20000"),
|
MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"),
|
||||||
AllowNets: env.netblocks("SWW_WAF_ALLOW_NETS", ""),
|
AllowNets: env.netblocks("SWW_ALLOW_NETS", ""),
|
||||||
RateLimitExemptNets: env.netblocks("SWW_WAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||||
DenyNets: env.netblocks("SWW_WAF_DENY_NETS", ""),
|
DenyNets: env.netblocks("SWW_DENY_NETS", ""),
|
||||||
RateLimitPerMinute: env.count("SWW_WAF_RATE_LIMIT_PER_MINUTE", "120"),
|
RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"),
|
||||||
RateLimitPerHour: env.count("SWW_WAF_RATE_LIMIT_PER_HOUR", "1500"),
|
RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"),
|
||||||
RateLimitPerDay: env.count("SWW_WAF_RATE_LIMIT_PER_DAY", "6000"),
|
RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"),
|
||||||
RateLimitExemptPaths: env.pathPrefixes("SWW_WAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||||
BytesLimitPerMinute: env.size("SWW_WAF_BYTES_LIMIT_PER_MINUTE", "off"),
|
BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"),
|
||||||
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"),
|
||||||
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
|
BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"),
|
||||||
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
|
BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"),
|
||||||
LookupSource: env.lookupSource("SWW_WAF_LOOKUP_SOURCE", "geojs"),
|
LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"),
|
||||||
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff("SWW_WAF_LOOKUP_TIMEOUT", "800ms"),
|
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
||||||
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
|
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
TagLists: env.tagLists("SWW_WAF_TAG_LISTS"),
|
TagLists: env.tagLists("SWW_TAG_LISTS"),
|
||||||
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
|
TagDeny: env.tags("SWW_TAG_DENY"),
|
||||||
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
|
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
|
||||||
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
|
RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"),
|
||||||
ASNBytesPercent: env.percents("SWW_WAF_ASN_BYTES_PERCENT", ParseASN),
|
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
||||||
CountryBytesPercent: env.percents("SWW_WAF_COUNTRY_BYTES_PERCENT", parseCountry),
|
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||||
UnknownLimitPercent: env.percent("SWW_WAF_UNKNOWN_LIMIT_PERCENT", "100"),
|
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||||
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
|
ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"),
|
||||||
BlocklistURLs: env.listURLs("SWW_WAF_BLOCKLIST_URLS"),
|
BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"),
|
||||||
BlocklistRefresh: env.refresh("SWW_WAF_BLOCKLIST_REFRESH", "24h"),
|
BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"),
|
||||||
DNSBLZones: env.zones("SWW_WAF_DNSBL_ZONES"),
|
DNSBLZones: env.zones("SWW_DNSBL_ZONES"),
|
||||||
DNSBLResolver: env.resolver("SWW_WAF_DNSBL_RESOLVER"),
|
DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"),
|
||||||
AbuseIPDBKey: env.secret("SWW_WAF_ABUSEIPDB_KEY"),
|
AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"),
|
||||||
AbuseIPDBMinScore: env.percent("SWW_WAF_ABUSEIPDB_MIN_SCORE", "75"),
|
AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"),
|
||||||
AbuseIPDBDailyBudget: env.numberNotOff("SWW_WAF_ABUSEIPDB_DAILY_BUDGET", "900"),
|
AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"),
|
||||||
ReputationCacheTTL: env.durationNotOff("SWW_WAF_REPUTATION_CACHE_TTL", "24h"),
|
ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"),
|
||||||
ReputationTimeout: env.durationNotOff("SWW_WAF_REPUTATION_TIMEOUT", "2s"),
|
ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"),
|
||||||
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_WAF_CROWDSEC_LAPI_URL"),
|
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"),
|
||||||
CrowdSecKey: env.secret("SWW_WAF_CROWDSEC_LAPI_KEY"),
|
CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"),
|
||||||
BanResponse: env.banResponse("SWW_WAF_BAN_RESPONSE", "403"),
|
BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"),
|
||||||
LimitBanDuration: env.durationNotOff("SWW_WAF_LIMIT_BAN_DURATION", "1h"),
|
LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"),
|
||||||
LimitBanRepeatWindow: env.durationNotOff("SWW_WAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||||
MaxBanDuration: env.durationNotOff("SWW_WAF_MAX_BAN_DURATION", "7d"),
|
MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"),
|
||||||
AttackBanDuration: env.durationNotOff("SWW_WAF_ATTACK_BAN_DURATION", "7d"),
|
AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"),
|
||||||
MaxBans: env.numberNotOff("SWW_WAF_MAX_BANS", "5000"),
|
MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"),
|
||||||
BanScopeV4Prefix: env.v4Prefix("SWW_WAF_BAN_SCOPE_V4_PREFIX", "32"),
|
BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"),
|
||||||
StateDir: env.absolutePath("SWW_WAF_STATE_DIR", "/var/lib/sww"),
|
StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"),
|
||||||
StateWriteDelay: env.durationNotOff("SWW_WAF_STATE_WRITE_DELAY", "10s"),
|
StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"),
|
||||||
StateCounterInterval: env.durationNotOff("SWW_WAF_STATE_COUNTER_INTERVAL", "15m"),
|
StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"),
|
||||||
CacheDir: env.absolutePath("SWW_WAF_CACHE_DIR", "/var/cache/sww"),
|
LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS",
|
||||||
LogRequestHeaders: env.headerNames("SWW_WAF_LOG_REQUEST_HEADERS",
|
|
||||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||||
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
|
LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"),
|
||||||
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
|
AdminToken: env.token("SWW_ADMIN_TOKEN"),
|
||||||
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
|
MetricsToken: env.token("SWW_METRICS_TOKEN"),
|
||||||
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
|
MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"),
|
||||||
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
|
RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"),
|
||||||
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
|
RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"),
|
||||||
WAFMode: env.wafMode("SWW_WAF_CORE_RULE_SET_MODE", WAFModeBlock),
|
WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock),
|
||||||
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL", "1"),
|
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"),
|
||||||
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", "5"),
|
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"),
|
||||||
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
|
WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES",
|
||||||
"920340,920420,920440,920640,930130,930140"),
|
"920340,920420,920440,920640,930130,930140"),
|
||||||
WAFExemptPaths: env.pathPrefixes("SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS", ""),
|
WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""),
|
||||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_CORE_RULE_SET_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWW_WAF_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
LogRemoteTLSCAs: env.certificates("SWW_WAF_LOG_REMOTE_TLS_CA_FILE"),
|
LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"),
|
||||||
LogRemoteBuffer: env.numberNotOff("SWW_WAF_LOG_REMOTE_BUFFER", "10000"),
|
LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"),
|
||||||
LogRemoteFacility: env.facility("SWW_WAF_LOG_REMOTE_FACILITY", "local0"),
|
LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"),
|
||||||
AlertWebhookURL: env.webhookURL("SWW_WAF_ALERT_WEBHOOK_URL"),
|
AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"),
|
||||||
AlertWebhookHeaders: env.webhookHeaders("SWW_WAF_ALERT_WEBHOOK_HEADERS"),
|
AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"),
|
||||||
AlertSlackWebhookURL: env.webhookURL("SWW_WAF_ALERT_SLACK_WEBHOOK_URL"),
|
AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"),
|
||||||
AlertNtfyURL: env.webhookURL("SWW_WAF_ALERT_NTFY_URL"),
|
AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"),
|
||||||
AlertNtfyToken: env.secret("SWW_WAF_ALERT_NTFY_TOKEN"),
|
AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"),
|
||||||
AlertEvents: env.alertEvents("SWW_WAF_ALERT_EVENTS",
|
AlertEvents: env.alertEvents("SWW_ALERT_EVENTS",
|
||||||
strings.Join(alerts.Events(), ",")),
|
strings.Join(alerts.Events(), ",")),
|
||||||
AlertCooldown: env.duration("SWW_WAF_ALERT_COOLDOWN", "15m"),
|
AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"),
|
||||||
AlertMaxPerHour: env.numberOrOff("SWW_WAF_ALERT_MAX_PER_HOUR", "60"),
|
AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"),
|
||||||
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
|
AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"),
|
||||||
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
|
AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"),
|
||||||
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
|
AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"),
|
||||||
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
|
AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"),
|
||||||
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
|
AnomalyWatch: env.thresholds("SWW_WATCH_"),
|
||||||
AnomalyNetV4Prefix: env.v4Prefix("SWW_WAF_ANOMALY_NET_V4_PREFIX", "24"),
|
AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"),
|
||||||
AnomalyNetV6Prefix: env.v6Prefix("SWW_WAF_ANOMALY_NET_V6_PREFIX", "48"),
|
AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"),
|
||||||
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
|
WatchNets: env.namedNetblocks("SWW_WATCH_NETS"),
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME",
|
||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||||
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
"SWW_BLOCKLIST_ACTION", "deny")
|
||||||
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
||||||
"SWW_WAF_REPUTATION_ACTION", "limit:25")
|
"SWW_REPUTATION_ACTION", "limit:25")
|
||||||
|
|
||||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||||
env.checkLookupDBPath(cfg)
|
env.checkLookupDBPath(cfg)
|
||||||
@@ -633,8 +629,8 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
// Command reads only SWW_COMMAND, the command the image runs and its
|
// Command reads only SWW_COMMAND, the command the image runs and its
|
||||||
// health check checks: rproxy when it is unset or empty, or waf. 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.
|
// be given as a file. Any other value is an error that names the setting.
|
||||||
func Command(v *viper.Viper) (string, error) {
|
func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||||
command, _ := readSetting(v, "SWW_COMMAND")
|
command, _ := lookupEnv("SWW_COMMAND")
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "", "rproxy":
|
case "", "rproxy":
|
||||||
@@ -646,22 +642,22 @@ func Command(v *viper.Viper) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
|
// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
|
||||||
// file, as FromEnvironment does. The health check in waf's container needs
|
// 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.
|
// no other setting, so it reads no other, nor a file that another names.
|
||||||
func WAFListenAddr(v *viper.Viper) (string, error) {
|
func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||||
env := &environment{v: v}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
listenAddr := env.address("SWW_WAF_LISTEN_ADDR")
|
listenAddr := env.address("SWW_LISTEN_ADDR")
|
||||||
|
|
||||||
return listenAddr, env.err
|
return listenAddr, env.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
|
// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a
|
||||||
// file, as FromEnvironment does, so that the line saying a setting is
|
// file, as FromEnvironment does, so that the line saying a setting is
|
||||||
// invalid carries it too. A file that cannot be read gives the default
|
// invalid carries it too. A file that cannot be read gives the default
|
||||||
// here, and FromEnvironment then stops the start over it.
|
// here, and FromEnvironment then stops the start over it.
|
||||||
func InstanceName(v *viper.Viper) string {
|
func InstanceName(lookupEnv func(string) (string, bool)) string {
|
||||||
env := &environment{v: v}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
|
|
||||||
return env.instanceName()
|
return env.instanceName()
|
||||||
}
|
}
|
||||||
@@ -677,23 +673,11 @@ func (c *Config) LogValue() slog.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// environment is where FromEnvironment reads the settings: it notes each
|
// environment is where FromEnvironment reads the settings: it notes each
|
||||||
// value for the log, and keeps the first error. It reads every setting
|
// value for the log, and keeps the first error.
|
||||||
// through v, the viper NewViper returns, and honours each setting's _FILE
|
|
||||||
// form too.
|
|
||||||
type environment struct {
|
type environment struct {
|
||||||
v *viper.Viper
|
lookupEnv func(string) (string, bool)
|
||||||
settings []slog.Attr
|
settings []slog.Attr
|
||||||
err error
|
err error
|
||||||
}
|
|
||||||
|
|
||||||
// readSetting returns a setting's value and whether it is set, reading it
|
|
||||||
// through v.
|
|
||||||
func readSetting(v *viper.Viper, name string) (string, bool) {
|
|
||||||
if !v.IsSet(name) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.GetString(name), true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// value returns a setting's value, or its default when it is not set,
|
// value returns a setting's value, or its default when it is not set,
|
||||||
@@ -714,10 +698,10 @@ func (e *environment) value(name, defaultValue string) string {
|
|||||||
// names, less one newline at their end. It notes that file's path for the
|
// names, less one newline at their end. It notes that file's path for the
|
||||||
// log. Both variables set, or a file that cannot be read, is an error.
|
// log. Both variables set, or a file that cannot be read, is an error.
|
||||||
func (e *environment) lookup(name string) (string, bool) {
|
func (e *environment) lookup(name string) (string, bool) {
|
||||||
value, set := readSetting(e.v, name)
|
value, set := e.lookupEnv(name)
|
||||||
fileName := name + "_FILE"
|
fileName := name + "_FILE"
|
||||||
|
|
||||||
path, inFile := readSetting(e.v, fileName)
|
path, inFile := e.lookupEnv(fileName)
|
||||||
if !inFile {
|
if !inFile {
|
||||||
return value, set
|
return value, set
|
||||||
}
|
}
|
||||||
@@ -909,7 +893,7 @@ func (e *environment) percents(
|
|||||||
return percents
|
return percents
|
||||||
}
|
}
|
||||||
|
|
||||||
// tagLists reads SWW_WAF_TAG_LISTS, which is empty by default.
|
// tagLists reads SWW_TAG_LISTS, which is empty by default.
|
||||||
func (e *environment) tagLists(name string) []TagList {
|
func (e *environment) tagLists(name string) []TagList {
|
||||||
lists, err := parseTagLists(e.value(name, ""))
|
lists, err := parseTagLists(e.value(name, ""))
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
@@ -1073,32 +1057,32 @@ func (e *environment) lookupSource(name, defaultValue string) string {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
|
// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without
|
||||||
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
|
// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source:
|
||||||
// one source at a time.
|
// one source at a time.
|
||||||
func (e *environment) checkLookupDBPath(cfg *Config) {
|
func (e *environment) checkLookupDBPath(cfg *Config) {
|
||||||
switch {
|
switch {
|
||||||
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
||||||
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
|
e.check("SWW_LOOKUP_SOURCE", fmt.Errorf(
|
||||||
"is file while SWW_WAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
|
"is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
|
||||||
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
|
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
|
||||||
e.check("SWW_WAF_LOOKUP_DB_PATH", fmt.Errorf(
|
e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf(
|
||||||
"is set while SWW_WAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
|
"is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCountriesAndLookups refuses a country in
|
// checkCountriesAndLookups refuses a country in
|
||||||
// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_WAF_TAG_DENY, and,
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
|
||||||
// while SWW_WAF_LOOKUP_SOURCE is off, each setting that needs clients looked
|
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
||||||
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
|
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
|
||||||
// an asn= or country= tag, the biased thresholds,
|
// an asn= or country= tag, the biased thresholds,
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
|
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
||||||
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1117,77 +1101,77 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
}{
|
}{
|
||||||
{"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
{"SWW_WAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
||||||
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
{"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
||||||
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
||||||
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||||
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||||
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||||
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
{"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
||||||
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
{"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
||||||
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
{"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
||||||
{"SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
|
{"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
|
||||||
{"SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
|
{"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
|
||||||
} {
|
} {
|
||||||
if setting.set {
|
if setting.set {
|
||||||
e.check(setting.name, fmt.Errorf("is set while SWW_WAF_LOOKUP_SOURCE is off; %w",
|
e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w",
|
||||||
errNeedsLookups))
|
errNeedsLookups))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
|
// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a
|
||||||
// blocklist too: the file at a URL is fetched as one list or the other.
|
// blocklist too: the file at a URL is fetched as one list or the other.
|
||||||
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
||||||
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
||||||
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
|
e.check("SWW_ASN_LIMIT_PERCENT_URL",
|
||||||
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
|
// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without
|
||||||
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
||||||
// that is fetched as another list too.
|
// that is fetched as another list too.
|
||||||
func (e *environment) checkCrowdSec(cfg *Config) {
|
func (e *environment) checkCrowdSec(cfg *Config) {
|
||||||
decisionsURL := cfg.CrowdSecDecisionsURL
|
decisionsURL := cfg.CrowdSecDecisionsURL
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
||||||
e.check("SWW_WAF_CROWDSEC_LAPI_URL", fmt.Errorf(
|
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf(
|
||||||
"is set while SWW_WAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
"is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
||||||
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
||||||
e.check("SWW_WAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
||||||
"is set while SWW_WAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
"is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
||||||
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
||||||
decisionsURL == cfg.ASNLimitPercentURL):
|
decisionsURL == cfg.ASNLimitPercentURL):
|
||||||
e.check("SWW_WAF_CROWDSEC_LAPI_URL",
|
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
||||||
fmt.Errorf("gives the decision list %q, which %w", decisionsURL, errAnotherList))
|
decisionsURL, errAnotherList))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as
|
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
|
||||||
// another list too: the file at a URL is read as one kind of list.
|
// another list too: the file at a URL is read as one kind of list.
|
||||||
func (e *environment) checkTagLists(cfg *Config) {
|
func (e *environment) checkTagLists(cfg *Config) {
|
||||||
for _, list := range cfg.TagLists {
|
for _, list := range cfg.TagLists {
|
||||||
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
||||||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
||||||
e.check("SWW_WAF_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
|
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would
|
// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would
|
||||||
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
||||||
// any tag these settings cannot give a request.
|
// any tag these settings cannot give a request.
|
||||||
func (e *environment) checkTags(cfg *Config) {
|
func (e *environment) checkTags(cfg *Config) {
|
||||||
for _, tag := range cfg.TagDeny {
|
for _, tag := range cfg.TagDeny {
|
||||||
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
|
e.check("SWW_TAG_DENY", checkTag(cfg, tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
for tag := range cfg.TagLimitPercent {
|
for tag := range cfg.TagLimitPercent {
|
||||||
e.check("SWW_WAF_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
|
e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,13 +1216,13 @@ func requestCanGetTag(cfg *Config, tag string) bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
|
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
||||||
// names an asn= or country= tag.
|
// names an asn= or country= tag.
|
||||||
func (c *Config) TagsNeedLookup() bool {
|
func (c *Config) TagsNeedLookup() bool {
|
||||||
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
|
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
|
||||||
// or country= tag.
|
// or country= tag.
|
||||||
func tagLimitNeedsLookups(cfg *Config) bool {
|
func tagLimitNeedsLookups(cfg *Config) bool {
|
||||||
for tag := range cfg.TagLimitPercent {
|
for tag := range cfg.TagLimitPercent {
|
||||||
@@ -1408,7 +1392,7 @@ func (e *environment) logRemoteURL(name string) *url.URL {
|
|||||||
// unset. Its value names a file already, so, unlike the other settings,
|
// unset. Its value names a file already, so, unlike the other settings,
|
||||||
// it has no _FILE form. Whoever uses the file reads it.
|
// it has no _FILE form. Whoever uses the file reads it.
|
||||||
func (e *environment) filePath(name string) string {
|
func (e *environment) filePath(name string) string {
|
||||||
path, _ := readSetting(e.v, name)
|
path, _ := e.lookupEnv(name)
|
||||||
e.settings = append(e.settings, slog.String(name, path))
|
e.settings = append(e.settings, slog.String(name, path))
|
||||||
|
|
||||||
return path
|
return path
|
||||||
@@ -1418,7 +1402,7 @@ func (e *environment) filePath(name string) string {
|
|||||||
// certificates. Unset or empty, it is nil. Its value names a file
|
// certificates. Unset or empty, it is nil. Its value names a file
|
||||||
// already, so, unlike the other settings, it has no _FILE form.
|
// already, so, unlike the other settings, it has no _FILE form.
|
||||||
func (e *environment) certificates(name string) *x509.CertPool {
|
func (e *environment) certificates(name string) *x509.CertPool {
|
||||||
path, _ := readSetting(e.v, name)
|
path, _ := e.lookupEnv(name)
|
||||||
e.settings = append(e.settings, slog.String(name, path))
|
e.settings = append(e.settings, slog.String(name, path))
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -1451,14 +1435,14 @@ func (e *environment) facility(name, defaultValue string) int {
|
|||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanceName reads SWW_WAF_INSTANCE_NAME, by default the host's name. It
|
// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It
|
||||||
// must be valid UTF-8: the metrics library panics on a label that is not.
|
// must be valid UTF-8: the metrics library panics on a label that is not.
|
||||||
func (e *environment) instanceName() string {
|
func (e *environment) instanceName() string {
|
||||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
|
|
||||||
value := e.value("SWW_WAF_INSTANCE_NAME", hostname)
|
value := e.value("SWW_INSTANCE_NAME", hostname)
|
||||||
if !utf8.ValidString(value) {
|
if !utf8.ValidString(value) {
|
||||||
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
@@ -1483,7 +1467,7 @@ func (e *environment) appName(
|
|||||||
case set:
|
case set:
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
||||||
case sending:
|
case sending:
|
||||||
e.check(name, fmt.Errorf("is unset, and SWW_WAF_INSTANCE_NAME %q, its default, %w",
|
e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w",
|
||||||
value, errNotAppName))
|
value, errNotAppName))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,22 +1475,22 @@ func (e *environment) appName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||||
// character while ntfySet, SWW_WAF_ALERT_NTFY_URL being set: ntfy is sent
|
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
||||||
// the instance name in a header, which cannot hold one.
|
// the instance name in a header, which cannot hold one.
|
||||||
func (e *environment) checkInstanceNameForNtfy(
|
func (e *environment) checkInstanceNameForNtfy(
|
||||||
instanceName string,
|
instanceName string,
|
||||||
ntfySet bool,
|
ntfySet bool,
|
||||||
) {
|
) {
|
||||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||||
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf(
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
||||||
"%q %w, and is sent to ntfy in a header while SWW_WAF_ALERT_NTFY_URL is set",
|
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
||||||
instanceName, errControlCharacter))
|
instanceName, errControlCharacter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhookURL reads a setting that is a URL each alert is posted to:
|
// webhookURL reads a setting that is a URL each alert is posted to:
|
||||||
// SWW_WAF_ALERT_WEBHOOK_URL, SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
|
// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWW_WAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
||||||
// there. The log shows ******** in place of its path and query, and an
|
// there. The log shows ******** in place of its path and query, and an
|
||||||
// error shows none of it, since a webhook or an ntfy topic can carry its
|
// error shows none of it, since a webhook or an ntfy topic can carry its
|
||||||
// secret there.
|
// secret there.
|
||||||
@@ -1958,7 +1942,7 @@ func parseCountry(value string) (string, error) {
|
|||||||
|
|
||||||
// ParseASN reads an AS number such as AS64496, in either case, and
|
// ParseASN reads an AS number such as AS64496, in either case, and
|
||||||
// returns it as the lookup gives it: AS and the number, in capitals and
|
// returns it as the lookup gives it: AS and the number, in capitals and
|
||||||
// without leading zeros. The file SWW_WAF_ASN_LIMIT_PERCENT_URL names is
|
// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is
|
||||||
// read with it too.
|
// read with it too.
|
||||||
func ParseASN(value string) (string, error) {
|
func ParseASN(value string) (string, error) {
|
||||||
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
|
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
|
||||||
@@ -1985,7 +1969,7 @@ const (
|
|||||||
ExploitSignatureTag = "exploitsignature"
|
ExploitSignatureTag = "exploitsignature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TagList is an address list of SWW_WAF_TAG_LISTS: the tag it adds to the
|
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
|
||||||
// requests of the clients it holds, and the URL it is fetched from.
|
// requests of the clients it holds, and the URL it is fetched from.
|
||||||
type TagList struct {
|
type TagList struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -2178,7 +2162,7 @@ func parsePercents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
|
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names is read with it too.
|
// SWW_ASN_LIMIT_PERCENT_URL names is read with it too.
|
||||||
func ParsePercent(value string) (int64, error) {
|
func ParsePercent(value string) (int64, error) {
|
||||||
percent, err := strconv.ParseInt(value, 10, 64)
|
percent, err := strconv.ParseInt(value, 10, 64)
|
||||||
if err != nil || percent < 0 || percent > 100 {
|
if err != nil || percent < 0 || percent > 100 {
|
||||||
@@ -2409,7 +2393,7 @@ const (
|
|||||||
maxLabelLength = 63
|
maxLabelLength = 63
|
||||||
// labelChars are the characters a label of a DNS zone may hold.
|
// labelChars are the characters a label of a DNS zone may hold.
|
||||||
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
||||||
// dnsPort is the port a resolver is asked on when SWW_WAF_DNSBL_RESOLVER
|
// dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER
|
||||||
// gives none.
|
// gives none.
|
||||||
dnsPort = 53
|
dnsPort = 53
|
||||||
)
|
)
|
||||||
|
|||||||
+155
-171
@@ -17,110 +17,106 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The settings, by name.
|
// The settings, by name.
|
||||||
const (
|
const (
|
||||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
mode = "SWW_WAF_MODE"
|
mode = "SWW_MODE"
|
||||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWW_WAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
tagLists = "SWW_WAF_TAG_LISTS"
|
tagLists = "SWW_TAG_LISTS"
|
||||||
tagDeny = "SWW_WAF_TAG_DENY"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH"
|
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||||
blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE"
|
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||||
abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET"
|
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||||
reputationAction = "SWW_WAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||||
routeWatchURL = "SWW_WAF_ROUTEWATCH_URL"
|
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||||
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
maxBans = "SWW_WAF_MAX_BANS"
|
maxBans = "SWW_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
stateDir = "SWW_WAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
cacheDir = "SWW_WAF_CACHE_DIR"
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
metricsTopN = "SWW_WAF_METRICS_TOP_N"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
logLevel = "SWW_LOG_LEVEL"
|
||||||
logLevel = "SWW_WAF_LOG_LEVEL"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
rulesEnabled = "SWW_RULES_ENABLED"
|
||||||
rulesEnabled = "SWW_WAF_RULES_ENABLED"
|
wafMode = "SWW_WAF_MODE"
|
||||||
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||||
wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
trapPaths = "SWW_WAF_TRAP_PATHS"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
logRemoteURL = "SWW_WAF_LOG_REMOTE_URL"
|
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||||
logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER"
|
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||||
logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY"
|
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||||
logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
|
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||||
alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS"
|
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||||
alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL"
|
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||||
alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL"
|
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||||
alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
alertEvents = "SWW_ALERT_EVENTS"
|
||||||
alertEvents = "SWW_WAF_ALERT_EVENTS"
|
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||||
alertCooldown = "SWW_WAF_ALERT_COOLDOWN"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
watchNets = "SWW_WAF_WATCH_NETS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||||
// followed by each of the four ends.
|
// followed by each of the four ends.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
watch = "SWW_WAF_WATCH_"
|
watch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -161,21 +157,21 @@ func loggedAnomalyDefaults() map[string]string {
|
|||||||
return logged
|
return logged
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and
|
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||||
// defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN.
|
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||||
const (
|
const (
|
||||||
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
|
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
|
||||||
"source_failure,file_error"
|
"source_failure,file_error"
|
||||||
defaultAlertCooldown = "15m"
|
defaultAlertCooldown = "15m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES.
|
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
|
|
||||||
@@ -200,8 +196,8 @@ const (
|
|||||||
otherToken = "fedcba9876543210fedcba9876543210"
|
otherToken = "fedcba9876543210fedcba9876543210"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and
|
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||||
// remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines.
|
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||||
const (
|
const (
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||||
@@ -216,7 +212,7 @@ const enabled = "true"
|
|||||||
// disabled is false, as a setting's value.
|
// disabled is false, as a setting's value.
|
||||||
const disabled = "false"
|
const disabled = "false"
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and
|
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
defaultLookupSource = "geojs"
|
defaultLookupSource = "geojs"
|
||||||
@@ -226,16 +222,18 @@ const (
|
|||||||
// environment is a set of environment variables, for FromEnvironment.
|
// environment is a set of environment variables, for FromEnvironment.
|
||||||
type environment map[string]string
|
type environment map[string]string
|
||||||
|
|
||||||
// viper returns a viper holding each of the variables.
|
// lookupEnv reads one of the variables, as os.LookupEnv does.
|
||||||
func (e environment) viper() *viper.Viper {
|
func (e environment) lookupEnv(name string) (string, bool) {
|
||||||
return configtest.Viper(e)
|
value, ok := e[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromEnvironment reads the settings from env, which must be valid.
|
// fromEnvironment reads the settings from env, which must be valid.
|
||||||
func fromEnvironment(t *testing.T, env environment) *config.Config {
|
func fromEnvironment(t *testing.T, env environment) *config.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(env.viper())
|
cfg, err := config.FromEnvironment(env.lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings %v: %v", env, err)
|
t.Fatalf("settings %v: %v", env, err)
|
||||||
}
|
}
|
||||||
@@ -264,7 +262,6 @@ func TestDefaults(t *testing.T) {
|
|||||||
StateDir: "/var/lib/sww",
|
StateDir: "/var/lib/sww",
|
||||||
StateWriteDelay: 10 * time.Second,
|
StateWriteDelay: 10 * time.Second,
|
||||||
StateCounterInterval: 15 * time.Minute,
|
StateCounterInterval: 15 * time.Minute,
|
||||||
CacheDir: "/var/cache/sww",
|
|
||||||
MetricsToken: "",
|
MetricsToken: "",
|
||||||
MetricsTopN: 50,
|
MetricsTopN: 50,
|
||||||
RulesDir: "/etc/sww/rules.d",
|
RulesDir: "/etc/sww/rules.d",
|
||||||
@@ -349,7 +346,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
stateDir: "/srv/waf-state",
|
stateDir: "/srv/waf-state",
|
||||||
stateWriteDelay: "500ms",
|
stateWriteDelay: "500ms",
|
||||||
stateCounterInterval: "1h",
|
stateCounterInterval: "1h",
|
||||||
cacheDir: "/srv/waf-cache",
|
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "10",
|
metricsTopN: "10",
|
||||||
rulesDir: "/srv/waf-rules",
|
rulesDir: "/srv/waf-rules",
|
||||||
@@ -372,7 +368,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
StateDir: "/srv/waf-state",
|
StateDir: "/srv/waf-state",
|
||||||
StateWriteDelay: 500 * time.Millisecond,
|
StateWriteDelay: 500 * time.Millisecond,
|
||||||
StateCounterInterval: time.Hour,
|
StateCounterInterval: time.Hour,
|
||||||
CacheDir: "/srv/waf-cache",
|
|
||||||
MetricsToken: token,
|
MetricsToken: token,
|
||||||
MetricsTopN: 10,
|
MetricsTopN: 10,
|
||||||
RulesDir: "/srv/waf-rules",
|
RulesDir: "/srv/waf-rules",
|
||||||
@@ -467,7 +462,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(
|
_, err := config.FromEnvironment(
|
||||||
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
|
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv)
|
||||||
|
|
||||||
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
|
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
|
||||||
`starting with /, such as /assets/`
|
`starting with /, such as /assets/`
|
||||||
@@ -522,7 +517,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -651,7 +646,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -776,7 +771,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
|
|||||||
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
||||||
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
||||||
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
||||||
}
|
}
|
||||||
@@ -793,7 +788,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
|
|||||||
t.Fatalf("write %s: %v", caFile, err)
|
t.Fatalf("write %s: %v", caFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
|
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -808,7 +803,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
|||||||
|
|
||||||
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
||||||
|
|
||||||
_, err := config.FromEnvironment(sending.viper())
|
_, err := config.FromEnvironment(sending.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||||
@@ -838,7 +833,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
|||||||
logRemoteURL: remoteURL,
|
logRemoteURL: remoteURL,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
logRemoteAppName: "my app",
|
logRemoteAppName: "my app",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as blog`
|
`characters without a space, such as blog`
|
||||||
@@ -969,7 +964,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
|
|||||||
// An item that is not a header is named by its place, not shown.
|
// An item that is not a header is named by its place, not shown.
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
alertWebhookHeaders: "X-Team:ops," + secret,
|
alertWebhookHeaders: "X-Team:ops," + secret,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
||||||
"and the header's value, such as Authorization:Bearer <token>"
|
"and the header's value, such as Authorization:Bearer <token>"
|
||||||
@@ -1006,7 +1001,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|||||||
"ftp://hooks.example/services/" + secret,
|
"ftp://hooks.example/services/" + secret,
|
||||||
"https://hooks.example/services/%zz" + secret,
|
"https://hooks.example/services/%zz" + secret,
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||||
"a fragment, such as https://alerts.example/sww"
|
"a fragment, such as https://alerts.example/sww"
|
||||||
@@ -1057,7 +1052,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
|
|||||||
"set": {alertNtfyToken: token + "\r"},
|
"set": {alertNtfyToken: token + "\r"},
|
||||||
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(env.viper())
|
_, err := config.FromEnvironment(env.lookupEnv)
|
||||||
|
|
||||||
want := alertNtfyToken + ": holds a control character, such as the " +
|
want := alertNtfyToken + ": holds a control character, such as the " +
|
||||||
"carriage return of a Windows line end"
|
"carriage return of a Windows line end"
|
||||||
@@ -1074,7 +1069,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||||
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
||||||
@@ -1099,7 +1094,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|||||||
"set": {instanceName: latin1},
|
"set": {instanceName: latin1},
|
||||||
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(env.viper())
|
_, err := config.FromEnvironment(env.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1114,10 +1109,9 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
tagDeny: "country=cn,country=ru",
|
tagDeny: "country=cn,country=ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ru in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and " +
|
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
||||||
"SWW_WAF_TAG_DENY was accepted")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||||
@@ -1177,7 +1171,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
|
|||||||
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
|
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
if err == nil || err.Error() != tc.want {
|
if err == nil || err.Error() != tc.want {
|
||||||
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
|
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1200,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
lookupSource: off, name: value,
|
lookupSource: off, name: value,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
||||||
!strings.Contains(err.Error(), lookupSource+" is off") {
|
!strings.Contains(err.Error(), lookupSource+" is off") {
|
||||||
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
||||||
@@ -1215,7 +1209,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
||||||
// lookup does not give, an anomaly threshold per AS number that is off,
|
// lookup does not give, an anomaly threshold per AS number that is off,
|
||||||
// any other anomaly threshold, or a blocklist.
|
// any other anomaly threshold, or a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
@@ -1357,7 +1351,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1451,14 +1445,14 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
|||||||
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
||||||
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(stopped.env.viper())
|
_, err := config.FromEnvironment(stopped.env.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
||||||
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
||||||
_, err := config.FromEnvironment(badURL.viper())
|
_, err := config.FromEnvironment(badURL.lookupEnv)
|
||||||
|
|
||||||
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
||||||
"without a user or a fragment, such as https://routewatch.example"
|
"without a user or a fragment, such as https://routewatch.example"
|
||||||
@@ -1513,7 +1507,7 @@ func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case tc.want == "" && err != nil:
|
case tc.want == "" && err != nil:
|
||||||
@@ -1551,7 +1545,7 @@ func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
|
|||||||
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
||||||
{tagLimitPercent, tag + ":50,,", emptyItem},
|
{tagLimitPercent, tag + ":50,,", emptyItem},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1573,7 +1567,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
|||||||
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
||||||
{
|
{
|
||||||
"the CrowdSec decision list", lapi + "/v1/decisions",
|
"the CrowdSec decision list", lapi + "/v1/decisions",
|
||||||
environment{"SWW_WAF_CROWDSEC_LAPI_URL": lapi, "SWW_WAF_CROWDSEC_LAPI_KEY": "key"},
|
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -1581,10 +1575,10 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
tc.env[tagLists] = "dup=" + tc.listURL
|
tc.env[tagLists] = "dup=" + tc.listURL
|
||||||
|
|
||||||
want := tagLists + `: "` + tc.listURL + `" is in SWW_WAF_BLOCKLIST_URLS, is ` +
|
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
||||||
"SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1653,7 +1647,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1671,7 +1665,7 @@ const (
|
|||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them.
|
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||||
const (
|
const (
|
||||||
actionDeny = "deny"
|
actionDeny = "deny"
|
||||||
actionLimit = "limit"
|
actionLimit = "limit"
|
||||||
@@ -1750,7 +1744,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1765,9 +1759,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too`
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1892,7 +1886,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1960,7 +1954,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
|||||||
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
||||||
{spamhaus + ",,", "has an empty item in its list"},
|
{spamhaus + ",,", "has an empty item in its list"},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := dnsblZones + ": " + tc.want
|
want := dnsblZones + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2013,7 +2007,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2070,7 +2064,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080"
|
"such as http://172.17.0.1:8080"
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||||
`SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too`
|
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -2127,7 +2121,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
if err == nil || err.Error() != tc.want {
|
if err == nil || err.Error() != tc.want {
|
||||||
t.Errorf("error %v, want %s", err, tc.want)
|
t.Errorf("error %v, want %s", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -2280,14 +2274,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
||||||
// files off, and either setting turning off the Core Rule Set, the tag
|
// files off, and either setting turning off the Core Rule Set, the tag
|
||||||
// exploitsignature in SWW_WAF_TAG_DENY stops the start.
|
// exploitsignature in SWW_TAG_DENY stops the start.
|
||||||
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
||||||
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
||||||
name, err, tagDeny)
|
name, err, tagDeny)
|
||||||
@@ -2308,7 +2302,6 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
|||||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
||||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||||
{cacheDir, ""}, {cacheDir, "cache"}, {cacheDir, "./var/cache/sww"},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2321,7 +2314,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
|
|||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
|
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
|
||||||
}
|
}
|
||||||
@@ -2346,7 +2339,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
|
|||||||
t.Run(value, func(t *testing.T) {
|
t.Run(value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
|
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
|
||||||
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
||||||
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
||||||
}
|
}
|
||||||
@@ -2363,7 +2356,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
|||||||
t.Run(name+"="+value, func(t *testing.T) {
|
t.Run(name+"="+value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{name: value}.viper())
|
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
|
||||||
|
|
||||||
want := name + ": is shorter than 32 characters"
|
want := name + ": is shorter than 32 characters"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2419,7 +2412,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
|
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2430,7 +2423,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|||||||
logRemoteURL: remoteURL,
|
logRemoteURL: remoteURL,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
|
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as blog`
|
`characters without a space, such as blog`
|
||||||
@@ -2445,7 +2438,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsToken + "_FILE": writeFile(t, token),
|
metricsToken + "_FILE": writeFile(t, token),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := metricsToken + ": is set, and so is " + metricsToken +
|
want := metricsToken + ": is set, and so is " + metricsToken +
|
||||||
"_FILE; set only one of them"
|
"_FILE; set only one of them"
|
||||||
@@ -2460,7 +2453,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
||||||
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
|
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
|
||||||
|
|
||||||
want := metricsToken + "_FILE: cannot be read: "
|
want := metricsToken + "_FILE: cannot be read: "
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||||
@@ -2581,7 +2574,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
stateDir: "/var/lib/sww",
|
stateDir: "/var/lib/sww",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
cacheDir: "/var/cache/sww",
|
|
||||||
adminToken: "",
|
adminToken: "",
|
||||||
metricsToken: "",
|
metricsToken: "",
|
||||||
metricsTopN: "50",
|
metricsTopN: "50",
|
||||||
@@ -2682,8 +2674,8 @@ func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantBanSettings checks the settings for bans, the state and cache
|
// wantBanSettings checks the settings for bans, the state files, the
|
||||||
// files, the metrics and the rule files.
|
// metrics and the rule files.
|
||||||
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -2702,7 +2694,11 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
|
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantStateSettings(t, got, want)
|
if got.StateDir != want.StateDir ||
|
||||||
|
got.StateWriteDelay != want.StateWriteDelay ||
|
||||||
|
got.StateCounterInterval != want.StateCounterInterval {
|
||||||
|
t.Errorf("state settings\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN {
|
if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN {
|
||||||
t.Errorf("metrics token %q and top %d, want %q and %d",
|
t.Errorf("metrics token %q and top %d, want %q and %d",
|
||||||
@@ -2710,18 +2706,6 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantStateSettings checks the settings for the state and cache files.
|
|
||||||
func wantStateSettings(t *testing.T, got *config.Config, want config.Config) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if got.StateDir != want.StateDir ||
|
|
||||||
got.StateWriteDelay != want.StateWriteDelay ||
|
|
||||||
got.StateCounterInterval != want.StateCounterInterval ||
|
|
||||||
got.CacheDir != want.CacheDir {
|
|
||||||
t.Errorf("state settings\n%+v\nwant\n%+v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantNetblocks checks a list of netblocks.
|
// wantNetblocks checks a list of netblocks.
|
||||||
func wantNetblocks(t *testing.T, got []netip.Prefix, want ...string) {
|
func wantNetblocks(t *testing.T, got []netip.Prefix, want ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
// Package configtest gives the tests of the packages that read settings the
|
|
||||||
// settings they choose.
|
|
||||||
package configtest
|
|
||||||
|
|
||||||
import "github.com/spf13/viper"
|
|
||||||
|
|
||||||
// Viper returns a viper holding each of settings, as the viper config.NewViper
|
|
||||||
// returns holds a process's environment.
|
|
||||||
func Viper(settings map[string]string) *viper.Viper {
|
|
||||||
v := viper.New()
|
|
||||||
for name, value := range settings {
|
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
+48
-37
@@ -8,39 +8,38 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||||
// is off.
|
// is off.
|
||||||
type RProxy struct {
|
type RProxy struct {
|
||||||
// ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR).
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
|
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
|
||||||
UpstreamURL *url.URL
|
UpstreamURL *url.URL
|
||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||||
// (SWW_RPROXY_TRUSTED_PROXIES).
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
|
// WAFBackend is waf's URL (SWRP_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for waf's answer to a question,
|
// Timeout is how long rproxy waits for waf's answer to a question,
|
||||||
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// DefaultDisposition is what is done with a request when waf 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:
|
// connected to, does not answer at all, or does not answer within Timeout:
|
||||||
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
DefaultDisposition string
|
DefaultDisposition string
|
||||||
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
|
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
|
||||||
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
|
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
|
||||||
MaxRequestSize int64
|
MaxRequestSize int64
|
||||||
MaxResponseSize int64
|
MaxResponseSize int64
|
||||||
// MaxRequestTime bounds sending the whole request: the visitor's request
|
// MaxRequestTime bounds sending the whole request: the visitor's request
|
||||||
// line and headers, and then, from their end, its body on its way to the
|
// line and headers, and then, from their end, its body on its way to the
|
||||||
// app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
||||||
// the end of the request to its last byte reaching the visitor
|
// the end of the request to its last byte reaching the visitor
|
||||||
// (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
|
// (SWRP_MAX_RESPONSE_TIME_MS).
|
||||||
MaxRequestTime time.Duration
|
MaxRequestTime time.Duration
|
||||||
MaxResponseTime time.Duration
|
MaxResponseTime time.Duration
|
||||||
|
|
||||||
@@ -59,26 +58,24 @@ var (
|
|||||||
"is not a whole number of milliseconds above zero, such as 60000, or off")
|
"is not a whole number of milliseconds above zero, such as 60000, or off")
|
||||||
)
|
)
|
||||||
|
|
||||||
// RProxyFromEnvironment reads rproxy's settings through v, normally the viper
|
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
|
||||||
// NewViper returns, bound to the process's environment. A setting that is not
|
// os.LookupEnv. A setting that is not set takes its default, and
|
||||||
// set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
|
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
|
||||||
// set. A setting may instead be given as a file: the variable named by the
|
// invalid is an error that names it. Since rproxy never touches disk, its
|
||||||
// setting's name with _FILE added names the file, which is read now. A setting
|
// settings have no _FILE form.
|
||||||
// that is set but invalid is an error that names it.
|
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
|
||||||
func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||||
env := &environment{v: v}
|
|
||||||
cfg := &RProxy{
|
cfg := &RProxy{
|
||||||
ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
|
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
|
||||||
UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
|
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
|
||||||
TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
|
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
|
||||||
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
|
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
|
||||||
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
|
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
|
||||||
DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
|
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
|
||||||
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
|
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
|
||||||
MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
|
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
|
||||||
MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
|
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
|
||||||
MaxResponseTime: env.millisecondsOrOff(
|
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
|
||||||
"SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
@@ -90,15 +87,15 @@ func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
|
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
|
||||||
// SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
|
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
|
||||||
// setting, so it reads no other.
|
// setting, so it reads no other.
|
||||||
func RProxyListenAddrAndUpstreamURL(
|
func RProxyListenAddrAndUpstreamURL(
|
||||||
v *viper.Viper,
|
lookupEnv func(string) (string, bool),
|
||||||
) (string, *url.URL, error) {
|
) (string, *url.URL, error) {
|
||||||
env := &environment{v: v}
|
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||||
listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
|
listenAddr := env.address("SWRP_LISTEN_ADDR")
|
||||||
upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
|
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return "", nil, env.err
|
return "", nil, env.err
|
||||||
@@ -107,6 +104,20 @@ func RProxyListenAddrAndUpstreamURL(
|
|||||||
return listenAddr, upstreamURL, nil
|
return listenAddr, upstreamURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
|
||||||
|
// rproxy never touches disk.
|
||||||
|
func withoutFileForms(
|
||||||
|
lookupEnv func(string) (string, bool),
|
||||||
|
) func(string) (string, bool) {
|
||||||
|
return func(name string) (string, bool) {
|
||||||
|
if strings.HasSuffix(name, "_FILE") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookupEnv(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LogValue makes an RProxy log as each setting's name with the value it was
|
// LogValue makes an RProxy log as each setting's name with the value it was
|
||||||
// given, or its default.
|
// given, or its default.
|
||||||
func (c *RProxy) LogValue() slog.Value {
|
func (c *RProxy) LogValue() slog.Value {
|
||||||
|
|||||||
@@ -10,21 +10,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// rproxy's settings, by name, and their values in these tests.
|
// rproxy's settings, by name, and their values in these tests.
|
||||||
const (
|
const (
|
||||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
|
||||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListen = "SWRP_LISTEN_ADDR"
|
||||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstream = "SWRP_UPSTREAM_URL"
|
||||||
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
|
||||||
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
|
||||||
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://waf:8080"
|
backendURL = "http://waf:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyDefaultAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
@@ -33,7 +32,11 @@ const (
|
|||||||
|
|
||||||
// rproxyConfig reads rproxy's settings from env.
|
// rproxyConfig reads rproxy's settings from env.
|
||||||
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
|
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
|
||||||
return config.RProxyFromEnvironment(configtest.Viper(env))
|
return config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxyDefaults(t *testing.T) {
|
func TestRProxyDefaults(t *testing.T) {
|
||||||
@@ -174,41 +177,31 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxySettingReadFromFile(t *testing.T) {
|
func TestRProxyReadsNoFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "backend")
|
path := filepath.Join(t.TempDir(), "backend")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
|
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||||
|
t.Errorf("error %v, want %s still required", err, wafBackend)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := rproxyConfig(map[string]string{
|
||||||
|
wafBackend: backendURL,
|
||||||
|
rproxyTimeout + "_FILE": "/nonexistent/timeout",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings: %v", err)
|
t.Fatalf("settings: %v, with the file left unread", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WAFBackend.String() != backendURL {
|
if cfg.Timeout != 2*time.Second {
|
||||||
t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL)
|
t.Errorf("timeout %v, want the default", cfg.Timeout)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
|
||||||
_, err := rproxyConfig(map[string]string{
|
|
||||||
wafBackend: backendURL,
|
|
||||||
rproxyTimeout + "_FILE": path,
|
|
||||||
})
|
|
||||||
|
|
||||||
want := rproxyTimeout + "_FILE: cannot be read: "
|
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
|
||||||
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "github.com/spf13/viper"
|
|
||||||
|
|
||||||
// NewViper returns the viper that reads the settings: each setting's
|
|
||||||
// environment variable, bound to its own name. Every setting of waf
|
|
||||||
// and rproxy is read through it, and none is read with os.Getenv or
|
|
||||||
// os.LookupEnv outside it. An environment variable that is set but empty is
|
|
||||||
// set, as one that holds a value is, which viper does not take for granted.
|
|
||||||
func NewViper() *viper.Viper {
|
|
||||||
v := viper.New()
|
|
||||||
v.AutomaticEnv()
|
|
||||||
v.AllowEmptyEnv(true)
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests set real environment variables, so none runs in parallel.
|
|
||||||
|
|
||||||
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
|
|
||||||
t.Setenv(adminToken, "")
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(config.NewViper())
|
|
||||||
|
|
||||||
want := adminToken + ": is shorter than 32 characters"
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Fatalf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListSetEmptyInTheEnvironmentReplacesItsDefault(t *testing.T) {
|
|
||||||
t.Setenv(wafBackend, backendURL)
|
|
||||||
t.Setenv(rproxyTrusted, "")
|
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(config.NewViper())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("settings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.TrustedProxies) != 0 {
|
|
||||||
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the fourteen changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the fifteen changes sww
|
||||||
// makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||||
// response.
|
// response.
|
||||||
//
|
//
|
||||||
@@ -32,19 +32,19 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||||
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
||||||
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
||||||
// and nextcloudExclusions for the ninth.
|
// nextcloudExclusions for the ninth, and forgejoExclusions for the tenth.
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
// setting switches one off. Coraza joins a line ending in \ to the next,
|
// setting switches one off. Coraza joins a line ending in \ to the next,
|
||||||
// without the spaces at the start of the next.
|
// without the spaces at the start of the next.
|
||||||
const directives = `
|
const directives = `
|
||||||
# The engine only detects. sww compares the request's anomaly
|
# The engine only detects. sww compares the request's anomaly
|
||||||
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
|
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||||
SecRuleEngine DetectionOnly
|
SecRuleEngine DetectionOnly
|
||||||
SecRequestBodyAccess Off
|
SecRequestBodyAccess Off
|
||||||
@@ -102,7 +102,10 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# So does the thirteenth.
|
# So does the thirteenth.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# And the fourteenth.
|
# So does the fourteenth.
|
||||||
|
%s
|
||||||
|
|
||||||
|
# And the fifteenth.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The sixth: only the rules for requests are loaded, and no response is
|
||||||
@@ -239,24 +242,32 @@ var vaultwardenExclusions string
|
|||||||
//go:embed nextcloud.conf
|
//go:embed nextcloud.conf
|
||||||
var nextcloudExclusions string
|
var nextcloudExclusions string
|
||||||
|
|
||||||
|
// forgejoExclusions are the fifteenth change: the fields of Forgejo's own
|
||||||
|
// requests that skip the rules that refused them there, where Forgejo differs
|
||||||
|
// from gitea, as Coraza directives.
|
||||||
|
//
|
||||||
|
//go:embed forgejo.conf
|
||||||
|
var forgejoExclusions string
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||||
// without, the rest of the fifth change.
|
// without, the rest of the fifth change. flash is Forgejo's name for
|
||||||
|
// gitea_flash.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
var cookiesNotRead = []string{"gitea_flash", "flash", "redirect_to"}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// ParanoiaLevel is SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4.
|
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
|
||||||
ParanoiaLevel int
|
ParanoiaLevel int
|
||||||
// DisabledRules are the ids of the rules switched off
|
// DisabledRules are the ids of the rules switched off
|
||||||
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
// (SWW_WAF_DISABLED_RULES).
|
||||||
DisabledRules []int
|
DisabledRules []int
|
||||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||||
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||||
BodyLimit int64
|
BodyLimit int64
|
||||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
// ExclusionsFile is the path of a file of exclusions loaded before the
|
||||||
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
}
|
}
|
||||||
@@ -282,7 +293,7 @@ var errSwwRuleID = errors.New("an id of sww's own rules")
|
|||||||
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
|
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
|
||||||
// the directive finds none of them.
|
// the directive finds none of them.
|
||||||
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
|
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
|
||||||
"switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " +
|
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
|
||||||
"with a ctl exclusion")
|
"with a ctl exclusion")
|
||||||
|
|
||||||
// directivesIn returns the directives in text as Coraza reads them: it
|
// directivesIn returns the directives in text as Coraza reads them: it
|
||||||
@@ -352,7 +363,7 @@ type CoreRuleSet struct {
|
|||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the fourteen changes, at params'
|
// New returns the Core Rule Set with the fifteen changes, at params'
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// paranoia level, without the rules it switches off, reading request
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// bodies up to params' limit, and with params' exclusions file loaded
|
||||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||||
@@ -382,7 +393,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||||
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
|
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
|
||||||
nextcloudExclusions)
|
nextcloudExclusions, forgejoExclusions)
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -470,7 +481,7 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readBody reads body, the body of the request in tx, which has run on
|
// readBody reads body, the body of the request in tx, which has run on
|
||||||
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
|
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
|
||||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||||
// when they are no longer than that, since they cannot be read in part.
|
// when they are no longer than that, since they cannot be read in part.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultDisabledRules are the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off
|
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
||||||
// by default.
|
// by default.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -204,7 +204,7 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a request one of the
|
// wantChange checks that crs lets through passes, a request one of the
|
||||||
// fourteen changes is for, and still finds result in refused, a request like
|
// fifteen changes is for, and still finds result in refused, a request like
|
||||||
// it that the change is not for.
|
// it that the change is not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||||
@@ -370,7 +370,7 @@ func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
|
|||||||
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
func TestFlashAndRedirectToCookiesAreNotRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -380,8 +380,12 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
|||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
|
// flash is Forgejo's name for gitea_flash.
|
||||||
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
|
for _, name := range []string{"gitea_flash", "flash"} {
|
||||||
|
wantChange(t, crs, get("/owner/repo", "Cookie: "+name+"="+flash),
|
||||||
|
get("/owner/repo", "Cookie: message="+flash), matched(930120))
|
||||||
|
}
|
||||||
|
|
||||||
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
||||||
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
||||||
|
|
||||||
@@ -815,7 +819,7 @@ func misfiringPasswords(t *testing.T) []string {
|
|||||||
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
misfiring := misfiringPasswords(t)
|
misfiring := misfiringPasswords(t)
|
||||||
@@ -846,7 +850,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
|||||||
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||||
@@ -866,7 +870,7 @@ func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
|||||||
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||||
body := passwordBody(t, r, []string{name}, attack)
|
body := passwordBody(t, r, []string{name}, attack)
|
||||||
@@ -938,7 +942,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
|
|||||||
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -986,7 +990,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
|||||||
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -1013,7 +1017,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
|||||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -1070,7 +1074,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||||
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_LIMIT.
|
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
|
||||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1137,7 +1141,7 @@ func wordpressAttackTargets(attack string) []string {
|
|||||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||||
|
|
||||||
@@ -1696,7 +1700,7 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
|||||||
wantBody(t, crs, signIn, body, matched(930120), body)
|
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
|
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||||
const bodyLimit = 8 << 10
|
const bodyLimit = 8 << 10
|
||||||
|
|
||||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
# request, before the rules it switches off, so this file is loaded before the
|
# request, before the rules it switches off, so this file is loaded before the
|
||||||
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
||||||
# exclusion on a field of a body applies only when the query has no parameter
|
# exclusion on a field of a body applies only when the query has no parameter
|
||||||
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is
|
# of that name, so it matters only while SWW_WAF_BODY_LIMIT is set. The
|
||||||
# set. The exclusions above the comment "The fields below are in bodies" also
|
# exclusions above the comment "The fields below are in bodies" also cover a
|
||||||
# cover a body field of the same name whatever the query holds, and only those
|
# body field of the same name whatever the query holds, and only those below it
|
||||||
# below it need a query without that name.
|
# need a query without that name.
|
||||||
#
|
#
|
||||||
# Each path may start with a language prefix, such as /fr in
|
# Each path may start with a language prefix, such as /fr in
|
||||||
# /fr/admin/content. The prefix is two or three letters, then optionally a
|
# /fr/admin/content. The prefix is two or three letters, then optionally a
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const drupalThreshold = 5
|
const drupalThreshold = 5
|
||||||
|
|
||||||
// drupalAddArticle is Drupal's form for a new article.
|
// drupalAddArticle is Drupal's form for a new article.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The fifteenth change: on Forgejo's paths, some fields skip the rules that
|
||||||
|
# refused ordinary Forgejo requests there. Forgejo is a fork of gitea, and the
|
||||||
|
# eighth change's fixes for gitea apply to its requests too, so this file holds
|
||||||
|
# only what Forgejo sends and gitea does not. Each exclusion runs with the
|
||||||
|
# request, before the rules it switches off, so this file is loaded before the
|
||||||
|
# Core Rule Set's rules. Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# Forgejo's search of a wiki sends the text searched for in q, which skips
|
||||||
|
# these rules there:
|
||||||
|
# 930120: .htaccess
|
||||||
|
# 932120: powershell
|
||||||
|
# 932160: /bin/sh
|
||||||
|
# 932250: ls -la
|
||||||
|
# 932260: python3
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/wiki/search$" \
|
||||||
|
"id:900680,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package coreruleset_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forgejoSearches are Forgejo's searches whose q skips some rules: the
|
||||||
|
// wiki's, from the fifteenth change, and the searches for a name, from the
|
||||||
|
// eighth.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var forgejoSearches = []string{
|
||||||
|
"/alice/tools/wiki/search",
|
||||||
|
"/explore/repos",
|
||||||
|
"/explore/users",
|
||||||
|
"/alice/tools/branches",
|
||||||
|
"/alice/-/packages",
|
||||||
|
"/api/v1/packages/alice",
|
||||||
|
"/alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgejoAttacks are an SQL injection, a path traversal, a script injection
|
||||||
|
// and a shell command after a ";", as query values.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var forgejoAttacks = []string{
|
||||||
|
sqlInjection, "..%2Fcustom%2Fconf%2Fapp.ini", scriptInjection, commandInjection,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
|
||||||
|
for _, path := range forgejoSearches {
|
||||||
|
for _, attack := range forgejoAttacks {
|
||||||
|
if got := inspect(t, defaults, get(path+"?q="+attack)); got.Score < threshold {
|
||||||
|
t.Errorf("GET %s?q=%s let through: %+v", path, attack, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same field in a form data body.
|
||||||
|
r := request{http.MethodPost, path, []string{formData}}
|
||||||
|
if got, _ := inspectBody(t, bodies, r, "q="+attack); got.Score < threshold {
|
||||||
|
t.Errorf("POST %s q=%s let through: %+v", path, attack, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
// /bin/sh and the hard values, which q lets through on the wiki's
|
||||||
|
// search, stay refused in q on paths near it and in another field on it.
|
||||||
|
// /alice/wiki/search would be the code search of a repository named
|
||||||
|
// wiki, where the eighth change lets q through. Forgejo has no owner
|
||||||
|
// named api, so /api/wiki/search is not one.
|
||||||
|
for _, target := range []string{
|
||||||
|
"/alice/tools/wiki/Home?q=",
|
||||||
|
"/alice/tools/wiki/search/x?q=",
|
||||||
|
"/api/wiki/search?q=",
|
||||||
|
"/alice/tools/wiki/search?other=",
|
||||||
|
} {
|
||||||
|
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
|
||||||
|
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||||
|
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,13 +102,16 @@ SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
|
|||||||
# name in q, which skips these rules there:
|
# name in q, which skips these rules there:
|
||||||
# 930120: .profile
|
# 930120: .profile
|
||||||
# 932120: powershell-scripts
|
# 932120: powershell-scripts
|
||||||
|
# 932250: ls -la
|
||||||
# 932260: python3-app
|
# 932260: python3-app
|
||||||
# 944130: org.apache.commons.logging
|
# 944130: org.apache.commons.logging
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
||||||
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
|
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search|\
|
||||||
|
api/v1/packages/[^/]+)$" \
|
||||||
"id:900602,phase:1,pass,nolog,\
|
"id:900602,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||||
|
|
||||||
@@ -116,19 +119,31 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
|||||||
# rules there:
|
# rules there:
|
||||||
# 930120: .profile
|
# 930120: .profile
|
||||||
# 932120: powershell-scripts
|
# 932120: powershell-scripts
|
||||||
|
# 932250: ls -la
|
||||||
# 932260: python3-app
|
# 932260: python3-app
|
||||||
# The searches:
|
# The searches:
|
||||||
# - the dashboard's filter of repositories;
|
# - the dashboard's filter of repositories;
|
||||||
# - the repositories on a user's or an organisation's page, whose path has
|
|
||||||
# one segment;
|
|
||||||
# - the author filter of the issue and pull request lists;
|
# - the author filter of the issue and pull request lists;
|
||||||
# - the lists of users and organisations, and the API's search for users.
|
# - the lists of users and organisations, and the API's search for users.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|\
|
||||||
explore/(?:users|organizations)|api/v1/users/search|\
|
explore/(?:users|organizations)|api/v1/users/search|\
|
||||||
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
|
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
|
||||||
"id:900603,phase:1,pass,nolog,\
|
"id:900603,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
|
|
||||||
|
# The repositories on a user's or an organisation's page are searched with q
|
||||||
|
# too. Their path has one segment, as other apps' pages such as /index.php do,
|
||||||
|
# so q keeps 932250 there and skips only these rules:
|
||||||
|
# 930120: .profile
|
||||||
|
# 932120: powershell-scripts
|
||||||
|
# 932260: python3-app
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+$" \
|
||||||
|
"id:900604,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
|
|
||||||
# These requests name a file in the path, which skips these rules there:
|
# These requests name a file in the path, which skips these rules there:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# exclusion runs with the request, before the rules it switches off, so this
|
# exclusion runs with the request, before the rules it switches off, so this
|
||||||
# file is loaded before the Core Rule Set's rules. Each comment names a request
|
# file is loaded before the Core Rule Set's rules. Each comment names a request
|
||||||
# a rule refused. An exclusion on a field of a body applies only when the query
|
# a rule refused. An exclusion on a field of a body applies only when the query
|
||||||
# has no parameter of that name, so it matters only while
|
# has no parameter of that name, so it matters only while SWW_WAF_BODY_LIMIT is
|
||||||
# SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# set.
|
||||||
#
|
#
|
||||||
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
|
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
|
||||||
# resources under them count as Mastodon's paths.
|
# resources under them count as Mastodon's paths.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// mastodonThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const mastodonThreshold = 5
|
const mastodonThreshold = 5
|
||||||
|
|
||||||
// mastodonStatuses is where Mastodon's apps and web app post.
|
// mastodonStatuses is where Mastodon's apps and web app post.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# request, before the rules it switches off, so this file is loaded before the
|
# request, before the rules it switches off, so this file is loaded before the
|
||||||
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
||||||
# exclusion on a field of a body applies only when the query has no parameter of
|
# exclusion on a field of a body applies only when the query has no parameter of
|
||||||
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# that name, so it matters only while SWW_WAF_BODY_LIMIT is set. MediaWiki's
|
||||||
# MediaWiki's paths are matched at the site root and under /w/.
|
# paths are matched at the site root and under /w/.
|
||||||
#
|
#
|
||||||
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
|
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
|
||||||
# with a page title, in the query or after the path:
|
# with a page title, in the query or after the path:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const mediawikiThreshold = 5
|
const mediawikiThreshold = 5
|
||||||
|
|
||||||
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
# switches off, so this file is loaded before the Core Rule Set's rules. Each
|
# switches off, so this file is loaded before the Core Rule Set's rules. Each
|
||||||
# comment names a request a rule refused. An exclusion on a field of a JSON body
|
# comment names a request a rule refused. An exclusion on a field of a JSON body
|
||||||
# applies only when the query has no parameter of that name, so it matters only
|
# applies only when the query has no parameter of that name, so it matters only
|
||||||
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
|
# while SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site
|
||||||
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
|
# root and under /index.php/, where a Nextcloud without pretty URLs serves them.
|
||||||
# serves them.
|
|
||||||
#
|
#
|
||||||
# Four rules read a search or a name as a command or a system file:
|
# Four rules read a search or a name as a command or a system file:
|
||||||
# 930120: .htaccess
|
# 930120: .htaccess
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const nextcloudThreshold = 5
|
const nextcloudThreshold = 5
|
||||||
|
|
||||||
// nextcloudSignIn is the sign-in page.
|
// nextcloudSignIn is the sign-in page.
|
||||||
@@ -380,7 +380,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
|
|||||||
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
target := nextcloudUsers + "/alice"
|
target := nextcloudUsers + "/alice"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
|
|||||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
|
||||||
@@ -149,6 +149,25 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
|||||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the
|
||||||
|
// defaults only. While bodies are read, the Core Rule Set still refuses the
|
||||||
|
// file and branch names some of them carry in a form or JSON body, as it does
|
||||||
|
// gitea's (TestGiteaBodiesTheCoreRuleSetRefuses).
|
||||||
|
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
for _, recorded := range recordedRequests(t, "forgejo-requests.txt") {
|
||||||
|
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||||
|
if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold {
|
||||||
|
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
# Ordinary Forgejo 17.0.1 requests that the Core Rule Set must let through at
|
||||||
|
# the defaults. They were recorded from a Forgejo driven by a headless Chrome,
|
||||||
|
# signed out, signed in and on the admin pages, by git over HTTP, by curl on
|
||||||
|
# the API and the generic package registry, and by crane on the container
|
||||||
|
# registry. Each line is one request as JSON. Names, addresses and passwords
|
||||||
|
# are invented. The browser's User-Agent is an ordinary Chrome's. A body or a
|
||||||
|
# multipart part that is not text is written as base64.
|
||||||
|
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/webcomponents.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/index.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/theme-forgejo-auto.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/index.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/logo.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/users", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/forgot_password", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/sign_up", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/sign_up", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 91", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/sign_up", "Accept-Encoding: gzip, deflate"], "body": "user_name=bob&email=bob%40example.com&password=Wd7-kPq3-Zx8n-Rf2m&retype=Wd7-kPq3-Zx8n-Rf2m"}
|
||||||
|
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/sign_up", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US; flash=success%3DAccount%2Bwas%2Bsuccessfully%2Bcreated.%2BWelcome%2521"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "Accept: */*", "Referer: http://example.com/", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/dashboard-repo-list.98057021.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/dashboard-repo-list.09995173.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?count_only=1&uid=2&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/events", "headers": ["Host: example.com", "Connection: keep-alive", "Pragma: no-cache", "Cache-Control: no-cache", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/event-stream", "Referer: http://example.com/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=2&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Frepo%2Fsearch%3Fsort%3Dupdated%26order%3Ddesc%26uid%3D2%26team_id%3Dundefined%26q%3D%26page%3D1%26limit%3D15%26mode%3D%26archived%3Dfalse"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 43", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Frepo%2Fsearch%3Fsort%3Dupdated%26order%3Ddesc%26uid%3D2%26team_id%3Dundefined%26q%3D%26page%3D1%26limit%3D15%26mode%3D%26archived%3Dfalse"], "body": "user_name=alice&password=Hv4-mQz8-Lr2x-Tp9w"}
|
||||||
|
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=2&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/create", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/webcomponents.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/index.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/index.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/theme-forgejo-auto.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/logo.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?count_only=1&uid=1&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=1&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/repo/create", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 179", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": "uid=1&repo_name=tools&description=Build+scripts+and+notes&repo_template=&auto_init=on&gitignores=&license=&readme=Default&issue_labels=&object_format_name=sha1&default_branch=main"}
|
||||||
|
{"method": "GET", "target": "/alice/tools", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/org/create", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/org/create", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 59", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": "org_name=acme&visibility=0&repo_admin_change_team_access=on"}
|
||||||
|
{"method": "GET", "target": "/org/acme/dashboard", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?count_only=1&uid=3&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=3&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools.git/info/refs?service=git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache", "Git-Protocol: version=2"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 181"], "body": "0014command=ls-refs\n001aagent=git/2.49.1-Linux0016object-format=sha100010009peel\n000csymrefs\n000bunborn\n001bref-prefix refs/heads/\n001aref-prefix refs/tags/\n0014ref-prefix HEAD\n0000"}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 223"], "body": "0011command=fetch001aagent=git/2.49.1-Linux0016object-format=sha10001000dthin-pack000fno-progress000dofs-delta0032want 025ecb5715ad88750bc8944ae3ee0023776b08a3\n0032want 025ecb5715ad88750bc8944ae3ee0023776b08a3\n0009done\n0000"}
|
||||||
|
{"method": "GET", "target": "/alice/tools.git/info/refs?service=git-receive-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 1195"], "body": "MDBiNTAyNWVjYjU3MTVhZDg4NzUwYmM4OTQ0YWUzZWUwMDIzNzc2YjA4YTMgYmJlNTIyM2E5YTRmODRlN2MzZDcyMDZmNGJkZDA2MjIzOWU4MzA5YiByZWZzL2hlYWRzL21haW4AIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAA+YDXiclYtbCsIwEAD/c4r8C7J5bLMFEXuUzWaLgVZLmoLHtx7B+RkYmN5UrRTyOCtLiSKEgCPMPnoix7FIRBiT5hSd2bjpq1vwqJIxOeRClBCy0Bgja1AF8CGlIQNxMHz057vZaami9sY/PfTD67boVd7r3bo0xkAOh8Fe4MScda2961+TmUqx+ahLsbu0uvXdfAHWnz/YoRR4nDM0MDAzMVHQS88syUzPyy9KZTiU2lsrbdjSY104jdX5NHPG05wrxw2hqjJKEpOTU4uLGaIm9ecuijp2bVeqB3fZBavC0xMSLaCKglwdXXxd9XJTGIra7y45/9VpvTvn0Xl2Kzsfiq1znAJVFJ1SlJhWEgtS9fmKeemzqGM9rJ0rpsnMS4t74ZzpYmIABApJmXkMS35PtDvsY3r29PzXKw5bNU9ZxpERBzUjJT85O7VINzk/tyC/OFWvMjeHQWd5y3flxTNnR91tzrz0g3Vzj8LbmxCzgIqLGfhebpt28ZJ3sYB3caHp4gO26QUZiyDSxclFmQUlxQxfH11qDNt8SW+e8ule1UMm87rDHz6HqihKZkjJPfl/b6+HvVWbwP4LLHLrGh0WWwIAQ5+D9D54nMvLT0mNz81PKc1JLdbnAgAqqgU4sQF4nPMvKMnMzytW0PXMS0mtSC3mAgA5BQYUNnicSylKTCvhAgAIQAIcrQJ4nDM0MDAzMVFIyU/OTi3STc0rKaosyM/MK2EwlNlx2uGU5d7E5T2We5qlPpa4xEUBAGp2Eai0AXicU1bUT8rM0y/O4EqtSE1WUFJxUOICADrbBTiyAnicK04tKstMTi224lJQKE9NAlEKCpm5iempVgp56Zl5FVwAvLIKdq8CeJwzNDAwMzFRyEosS9TLScxL1wsuKcoEUrkpDDqcykduszK1ZnX7O/2+3DJ3wducWABo9hG0OXicU1YILinKzEvnAgANQQLFqQV4nDM0MDAzMVHIzCsuSczJ0S1OLsosKNErzmA4WsLziFHnpxzTZC5LhjXrhBeuDpcyhCguqCzJyM8z1k3OSE3O1iuoZNjBZ6S0Ws37+qnf5umeeyUa/a+llwEAQ3sf9roBeJxTVtRPyszTL87gSk3OyFfIzCsuSczJycxL5wIAbs0Ivzx4nCsoyswr0VDKyFTS5AIAHBwDnqwCeJwzNDAwMzFRKCjKT04tLtZLzSvTS9ErKWZoZrzwV9ki/dEqV+5di3c4P5qYE/gAAFOgEcS7AnicS0lNzkksSlVIzs8rLlFIzSuzUghKTc4vSrEpLinKzEvXUYDQdtZcAF6zD3MdeaAnWyIMS8cNQ71Ry9eDVrG43w=="}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 617"], "body": "MDBjMjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgZjdmZmE2MDM5NmQ5OWY2MTMxNWE1YTlhMjI1ZmMzMmViNjIyOGZkYiByZWZzL2hlYWRzL3B5dGhvbjMtbWlncmF0aW9uACByZXBvcnQtc3RhdHVzLXYyIHNpZGUtYmFuZC02NGsgcXVpZXQgb2JqZWN0LWZvcm1hdD1zaGExIGFnZW50PWdpdC8yLjQ5LjEtTGludXgwMDAwUEFDSwAAAAIAAAAElg14nJWNSwoCMRBE9zlF7wVJ0j35gIgewEMk6Q4OzJhhiKK3Nx7B2hS8R1F9F4GKwoGzUAmFinHOMCUO1gyKjrC6yDb6qLa0y6NDzjJZiykmqoHEF2RvtauUmbUbJkpAHbNKz35vO1yXuQic0q8u8k7rtsixtPUMxkfCYCbn4aBH1KDr3Lv8NVK39hLoDbbP+Hug+gLJoT+v+gPNgl/qzUzIUFkPJCiBpNxFCX63QXicO8h0kGkDP6NRbPrloHwvB+O3XZczPxVe7CwzvnPLxAAIFIqLkhlSck/+39vrYW/VJrD/AovcukaHxZYA3WMY768HeJwzNDAwMzFRyMwrLknMydEtTi7KLCjRK85gOFrC84hR56cc02QuS4Y164QXrg6XMoQozs1ML0osSdUrqGTYKb5c7aT9BD1nTdkJnNc8mluFrdOhqgoqSzLy84x1kzNSk7NBanfwGSmtVvO+fuq3ebrnXolG/2vpZQCoeiylOXicKyjKzCvRMNTkAgAQzwK6yhVHjb1hxkRjVbbQfVlakFC62Bw="}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 475"], "body": "MDBjNDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgNGI1ZWMxYWFiNjUyNzJiNjNiNWIxZjM5YmZjMmMxNDY4NGMwNjNhMCByZWZzL2hlYWRzL2ZlYXQvZG9ja2VyLXN1cHBvcnQAIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAAOVDXiclYtbCsIwEEX/s4r8CzLNswERC25kJjODxdaWEMHlW5fg/TlwDrc3EesIQyoeoOQQaXCclFExhsqOKEQFUFJwZscmr26JJDrnsWDQMUiunrODpIGYIR2lyOihkMF3f2zNTstcxV7wh5t8cN0XOddtvdohl+DHIaZsT3DMHHade5e/TmZitvetPqXpvIj5AnIRP0P5As2CX+rNTMhQWQ8kKIGk3EUJfrdBeJw7yPScaUKQvEt+cnZqUVpmTipD+l/zCRITa+JfuB2Sut6UzLn8RVLLRO9vAGSzEms8eJxzC/L3VUjMKcjMS+UCABjGA9h6SWeBbtf5prUzWzGtbNHU1VZzAQ=="}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 218"], "body": "MDBiNjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgYmJlNTIyM2E5YTRmODRlN2MzZDcyMDZmNGJkZDA2MjIzOWU4MzA5YiByZWZzL3RhZ3MvdjEuMC4wACByZXBvcnQtc3RhdHVzLXYyIHNpZGUtYmFuZC02NGsgcXVpZXQgb2JqZWN0LWZvcm1hdD1zaGExIGFnZW50PWdpdC8yLjQ5LjEtTGludXgwMDAwUEFDSwAAAAIAAAAAAp0IgjvYqOq1EK1qx1yCPP0+0x4="}
|
||||||
|
{"method": "GET", "target": "/alice/powershell-scripts.git/info/refs?service=git-receive-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/powershell-scripts.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 397"], "body": "MDBiNTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgZGQxYzIyM2RjZWI1NDQxMjdiNzhjZGMyYTZjZTM1NmY4ZWQ5NGYyZCByZWZzL2hlYWRzL21haW4AIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAAObCXiclYtRCsIwEAX/c4r9FyTbmO0GRPQoSXzBQGOlrODxrUdwfgYGxjaARNsEbjJ5pCRVYp0DtxCKIkfPUqLXO6O4/LbHutFt6RV0zj9d8cnjteBY13EhntMpKEdROvgdt9fRzfDX5Pqzm/sCJg8twKUCeJwzNDAwMzFRCHJ1dPF11ctNYXDttjkS9vTmppsC4WHfVpXW8TJZuQIA3ioNSjp4nFNWKE4uyiwoKeYCABGaA1YUrPZOD6Y21E0qY7z1OfdB9DOSug=="}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 212"], "body": "0014command=ls-refs\n001aagent=git/2.49.1-Linux0016object-format=sha100010009peel\n000csymrefs\n000bunborn\n001bref-prefix refs/heads/\n001fref-prefix refs/heads/main\n001aref-prefix refs/tags/\n0014ref-prefix HEAD\n0000"}
|
||||||
|
{"method": "GET", "target": "/assets/js/activity-heatmap.514f8f16.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/bin/docker-entrypoint", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/src/process.env.d.ts", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/docs/java.lang.String.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/%5Bdraft%5D.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/raw/branch/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/raw/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/blame/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/feat/docker-support/Dockerfile", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/tags", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/activity", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/repo-activity-top-authors-contributors-graph-code-frequency-graph-recent-commits-graph.2afcdb46.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/repo-activity-top-authors.0b8b147c.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/graph", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/compare/main...python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/dropzone.5a752d14.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/dropzone.265e21d7.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/tag/v1.0.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/archive/main.zip", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: session=d65379616ffd0971; lang=en-US", "Host: example.com", "Connection: keep-alive"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice?tab=repositories&q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=python3&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=powershell&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=ls%20-la&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=.htaccess&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=docker-compose.yml&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=install-script.sh&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=%2Fbin%2Fsh&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/repos?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/code?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/issues?type=your_repositories&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/organizations?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=repositories&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=release%20notes&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/codemirror-----------------.850d6e6e.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/codemirror.f4be46b8.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/----------------.de1a9813.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/vendors-node_modules_codemirror_lang-yaml_dist_index_js.4f1c0b18.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 278", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "last_commit=bbe5223a9a4f84e7c3d7206f4bdd062239e8309b&page_has_posted=&tree_path=docker-compose.yml&content=services%3A%0D%0A++web%3A%0D%0A++++image%3A+nginx%3A1.27%0D%0A&commit_summary=Pin+nginx&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 272", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "last_commit=2a8b9144333b5cb8075a74c64b31dbaa7ea02b3c&page_has_posted=&tree_path=docs%2Fpython3-setup.md&content=%23+Setup%0D%0A%0D%0AInstall+the+tools.%0D%0A&commit_summary=Add+setup+notes&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "GET", "target": "/assets/js/---.8673fbd3.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/------.2befb329.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/vendors-node_modules_codemirror_lang-markdown_dist_index_js.eef88d50.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/docs/python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_upload/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 224", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4q9JlyMIwlkOQ1ZY", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundary4q9JlyMIwlkOQ1ZY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n------WebKitFormBoundary4q9JlyMIwlkOQ1ZY--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_upload/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 229", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "tree_path=&files=cde01135-f509-483d-af4a-56d1f754aa2c&files_fullpath%5Bcde01135-f509-483d-af4a-56d1f754aa2c%5D=notes.txt&commit_summary=Add+notes&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/labels", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/colorpicker.6c3c1b7d.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/labels", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/colorpicker.eb94c3c5.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/labels", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/milestones/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/milestones/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 36", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestones/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "title=Version+1.1&deadline=&content="}
|
||||||
|
{"method": "GET", "target": "/alice/tools/milestones", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestones/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DThe%2Bmilestone%2B%2522Version%2B1.1%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/issues/attachments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 5018", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZATiKc8Kvm2f9kjJ", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryZATiKc8Kvm2f9kjJ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"screenshot.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAAEAAAAAoCAIAAADBrGu+AAASpklEQVR4nBXYkZ+0UBTG8YENNthggwk22GCCDSaYYJIkSZIkSZIkSX6SJEmSJEmSJEmSJMnKyMjKysrKyMjIyrzz9hfc7/2ce85z2mw2CBvEDdIGeYOyQd2gbdA3GBvMDdYGe4Ozwd3gbfA3BBvCDdGGeEOyId2QbWBDvqHYUG6oNtQbmg3thm5Dv2HYMG6YNswblg3rhs8Npw3nDV8bvjf8bPjdcNlw3XDb8LfhvuFpw/OGlw2vG7Yb3ja8b9ht+Niw33DYcNyw2QgIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisAp8CJ4GzwJfAt8CPwK/AReAqcBP4E7gLPAk8C7wIvApsBd4E3gV2Ah8Ce4GDwFF4AEQEEVFEEpFFFBFVRBPRRQwRU8QSsUUcEVfEE/FFApFQJBKJRRKRVCQTQSQXKURKkUqkFmlEWpFOpBcZREaRSWQWWURWkU+Rk8hZ5EvkW+RH5FfkInIVuYn8idxFnkSeRV5EXkW2Im8i7yI7kQ+RvchB5Cg+ABKChCghScgSioQqoUnoEoaEKWFJ2BKOhCvhSfgSgUQoEUnEEolEKpFJIJFLFBKlRCVRSzQSrUQn0UsMEqPEJDFLLBKrxKfESeIs8SXxLfEj8StxkbhK3CT+JO4STxLPEi8SrxJbiTeJd4mdxIfEXuIgcZQeABlBRpSRZGQZRUaV0WR0GUPGlLFkbBlHxpXxZHyZQCaUiWRimUQmlclkkMllCplSppKpZRqZVqaT6WUGmVFmkpllFplV5lPmJHOW+ZL5lvmR+ZW5yFxlbjJ/MneZJ5lnmReZV5mtzJvMu8xO5kNmL3OQOcoPgIKgICpICrKCoqAqaAq6gqFgKlgKtoKj4Cp4Cr5CoBAqRAqxQqKQKmQKKOQKhUKpUCnUCo1Cq9Ap9AqDwqgwKcwKi8Kq8KlwUjgrfCl8K/wo/CpcFK4KN4U/hbvCk8KzwovCq8JW4U3hXWGn8KGwVzgoHJUHQEVQEVUkFVlFUVFVNBVdxVAxVSwVW8VRcVU8FV8lUAlVIpVYJVFJVTIVVHKVQqVUqVRqlUalVelUepVBZVSZVGaVRWVV+VQ5qZxVvlS+VX5UflUuKleVm8qfyl3lSeVZ5UXlVWWr8qbyrrJT+VDZqxxUjuoDoCFoiBqShqyhaKgamoauYWiYGpaGreFouBqehq8RaIQakUaskWikGpkGGrlGoVFqVBq1RqPRanQavcagMWpMGrPGorFqfGqcNM4aXxrfGj8avxoXjavGTeNP467xpPGs8aLxqrHVeNN419hpfGjsNQ4aR+0B0BF0RB1JR9ZRdFQdTUfXMXRMHUvH1nF0XB1Px9cJdEKdSCfWSXRSnUwHnVyn0Cl1Kp1ap9FpdTqdXmfQGXUmnVln0Vl1PnVOOmedL51vnR+dX52LzlXnpvOnc9d50nnWedF51dnqvOm86+x0PnT2Ogedo/4AGAgGooFkIBsoBqqBZqAbGAamgWVgGzgGroFn4BsEBqFBZBAbJAapQWaAQW5QGJQGlUFt0Bi0Bp1BbzAYjAaTwWywGKwGnwYng7PBl8G3wY/Br8HF4GpwM/gzuBs8GTwbvBi8GmwN3gzeDXYGHwZ7g4PB0XgATAQT0UQykU0UE9VEM9FNDBPTxDKxTRwT18Qz8U0Ck9AkMolNEpPUJDPBJDcpTEqTyqQ2aUxak86kNxlMRpPJZDZZTFaTT5OTydnky+Tb5Mfk1+RicjW5mfyZ3E2eTJ5NXkxeTbYmbybvJjuTD5O9ycHkaD4AFoKFaCFZyBaKhWqhWegWhoVpYVnYFo6Fa+FZ+BaBRWgRWcQWiUVqkVlgkVsUFqVFZVFbNBatRWfRWwwWo8VkMVssFqvFp8XJ4mzxZfFt8WPxa3GxuFrcLP4s7hZPFs8WLxavFluLN4t3i53Fh8Xe4mBxtB4AG8FGtJFsZBvFRrXRbHQbw8a0sWxsG8fGtfFsfJvAJrSJbGKbxCa1yWywyW0Km9KmsqltGpvWprPpbQab0WaymW0Wm9Xm0+Zkc7b5svm2+bH5tbnYXG1uNn82d5snm2ebF5tXm63Nm827zc7mw2Zvc7A52g+Ag+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+rw6XByODt8OXw7/Dj8Olwcrg43hz+Hu8OTw7PDi8Orw9bhzeHdYefw4bB3ODgcnQfARXARXSQX2UVxUV00F93FcDFdLBfbxXFxXTwX3yVwCV0il9glcUldMhdccpfCpXSpXGqXxqV16Vx6l8FldJlcZpfFZXX5dDm5nF2+XL5dflx+XS4uV5eby5/L3eXJ5dnlxeXVZevy5vLusnP5cNm7HFyO7gPgIXiIHpKH7KF4qB6ah+5heJgeloft4Xi4Hp6H7xF4hB6RR+yReKQemQceuUfhUXpUHrVH49F6dB69x+Axekwes8fisXp8epw8zh5fHt8ePx6/HhePq8fN48/j7vHk8ezx4vHqsfV483j32Hl8eOw9Dh5H7wHwEXxEH8lH9lF8VB/NR/cxfEwfy8f2cXxcH8/H9wl8Qp/IJ/ZJfFKfzAef3KfwKX0qn9qn8Wl9Op/eZ/AZfSaf2WfxWX0+fU4+Z58vn2+fH59fn4vP1efm8+dz93nyefZ58Xn12fq8+bz77Hw+fPY+B5+j/wAECAFigBQgBygBaoAWoAcYAWaAFWAHOAFugBfgBwQBYUAUEAckAWlAFkBAHlAElAFVQB3QBLQBXUAfMASMAVPAHLAErAGfAaeAc8BXwHfAT8BvwCXgGnAL+Au4BzwFPAe8BLwGbAPeAt4DdgEfAfuAQ8AxeABChBAxRAqRQ5QQNUQL0UOMEDPECrFDnBA3xAvxQ4KQMCQKiUOSkDQkCyEkDylCypAqpA5pQtqQLqQPGULGkClkDllC1pDPkFPIOeQr5DvkJ+Q35BJyDbmF/IXcQ55CnkNeQl5DtiFvIe8hu5CPkH3IIeQYPgARQoQYIUXIEUqEGqFF6BFGhBlhRdgRToQb4UX4EUFEGBFFxBFJRBqRRRCRRxQRZUQVUUc0EW1EF9FHDBFjxBQxRywRa8RnxCniHPEV8R3xE/EbcYm4Rtwi/iLuEU8RzxEvEa8R24i3iPeIXcRHxD7iEHGMHoAYIUaMkWLkGCVGjdFi9BgjxoyxYuwYJ8aN8WL8mCAmjIli4pgkJo3JYojJY4qYMqaKqWOamDami+ljhpgxZoqZY5aYNeYz5hRzjvmK+Y75ifmNucRcY24xfzH3mKeY55iXmNeYbcxbzHvMLuYjZh9ziDnGD0CCkCAmSAlygpKgJmgJeoKRYCZYCXaCk+AmeAl+QpAQJkQJcUKSkCZkCSTkCUVCmVAl1AlNQpvQJfQJQ8KYMCXMCUvCmvCZcEo4J3wlfCf8JPwmXBKuCbeEv4R7wlPCc8JLwmvCNuEt4T1hl/CRsE84JByTByBFSBFTpBQ5RUlRU7QUPcVIMVOsFDvFSXFTvBQ/JUgJU6KUOCVJSVOyFFLylCKlTKlS6pQmpU3pUvqUIWVMmVLmlCVlTflMOaWcU75SvlN+Un5TLinXlFvKX8o95SnlOeUl5TVlm/KW8p6yS/lI2accUo7pA5AhZIgZUoacoWSoGVqGnmFkmBlWhp3hZLgZXoafEWSEGVFGnJFkpBlZBhl5RpFRZlQZdUaT0WZ0GX3GkDFmTBlzxpKxZnxmnDLOGV8Z3xk/Gb8Zl4xrxi3jL+Oe8ZTxnPGS8ZqxzXjLeM/YZXxk7DMOGcfsAfj/QwTx/1L+WGsfi+FjtXosJ494/wjIj4j5CGmPmPMICo9R+xhWj3b/aJiPlvN4tI+yfxTO4+ofh///5VBACRXU0EALHfQwwAgTzLDACp9wgjN8wTf8wC9c4Ao3+IM7PMEzvMArbOEN3mEHH7CHAxx5AHKEHDFHypFzlBw1R8vRc4wcM8fKsXOcHDfHy/FzgpwwJ8qJc5KcNCfL/x8/zylyypwqp85pctqcLqfPGXLGnClnzlly1pzPnFPOOecr5zvnJ+c355Jzzbnl/OXcc55ynnNecl5ztjlvOe85u5yPnH3OIeeYPwAFQoFYIBXIBUqBWqLine truncated
|
||||||
|
{"method": "POST", "target": "/alice/tools/issues/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 1281", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarypBVS0yCpwcYAG4g1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nBuild fails on a clean checkout\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nThe build stops after the first step. It works on the second try.\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"files\"\r\n\r\n179d62f5-4ae4-4e5e-9893-269ae399ca12\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"files_fullpath[179d62f5-4ae4-4e5e-9893-269ae399ca12]\"\r\n\r\nscreenshot.png\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"ref\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"edit_mode\"\r\n\r\ntrue\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"search\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"label_ids\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"milestone_id\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"project_id\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"assignee_ids\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1--\r\n"}
|
||||||
|
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 38", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fissues%2F1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/1/content-history/overview", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/attachments/179d62f5-4ae4-4e5e-9893-269ae399ca12", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/issues/1/comments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 163", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryuLA6czyWTpPLGhxN", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryuLA6czyWTpPLGhxN\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nThanks, I can see it too.\r\n------WebKitFormBoundaryuLA6czyWTpPLGhxN--\r\n"}
|
||||||
|
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 55", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fissues%2F1%23issuecomment-1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/releases/attachments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 227", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarygH3DxdI69SJCwZ2b", "Origin: http://example.com", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarygH3DxdI69SJCwZ2b\r\nContent-Disposition: form-data; name=\"file\"; filename=\"tools-linux-amd64.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\neJwryc/PKVZIysxLLKosGQFsAIkDYLk=\r\n------WebKitFormBoundarygH3DxdI69SJCwZ2b--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/releases/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 261", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "tag_name=v1.1.0&tag_target=main&title=Version+1.1.0&content=Faster+builds.&attachment-template-new-name=&attachment-template-new-exturl=&files=a637a7ba-b419-4ec3-8c33-1c119e93bc14&files_fullpath%5Ba637a7ba-b419-4ec3-8c33-1c119e93bc14%5D=tools-linux-amd64.tar.gz"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 78", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "title=Getting+started&content=Clone+the+repository+and+run+the+build.&message="}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Getting-started", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Getting-started", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/?action=_pages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/settings/avatar", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 4377", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryR5Mv0yttLbvaY13B", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryR5Mv0yttLbvaY13B\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAQJ0lEQVR4nBXXrbpFUBSG0V0URVEURVEURVEURVEURVEURXmLoiiKoiiKoiiKoiiKoiiKoriJ47iC8Vjz55u/3w/hh/hD+iH/UH6oP7Qf+g/jh/nD+mH/cH64P7wf/o/gR/gj+hH/SH6kP7If/Mh/FD/KH9WP+kfzo/3R/eh/DD/GH9OP+cfyY/2x/dh/HD/OH9eP+8fz4/3x+wkIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisApvALnAInAKXwC3wCLzCBxIRREQRSUQWUURUEU1EFzFETBFLxBZxRFwRT8QXCURCkUgkFklEUpFMBJFcpBApRSqRWqQRaUU6kV5kEBlFJpFZZBFZRTaRXeQQOUUukVvkEXnFDyQhSIgSkoQsoUioEpqELmFImBKWhC3hSLgSnoQvEUiEEpFELJFIpBKZBBK5RCFRSlQStUQj0Up0Er3EIDFKTBKzxCKxSmwSu8QhcUpcErfEI/FKH0hGkBFlJBlZRpFRZTQZXcaQMWUsGVvGkXFlPBlfJpAJZSKZWCaRSWUyGWRymUKmlKlkaplGppXpZHqZQWaUmWRmmUVmldlkdplD5pS5ZG6ZR+aVP5CCoCAqSAqygqKgKmgKuoKhYCpYCraCo+AqeAq+QqAQKkQKsUKikCpkCijkCoVCqVAp1AqNQqvQKfQKg8KoMCnMCovCqrAp7AqHwqlwKdwKj8KrfCAVQUVUkVRkFUVFVdFUdBVDxVSxVGwVR8VV8VR8lUAlVIlUYpVEJVXJVFDJVQqVUqVSqVUalValU+lVBpVRZVKZVRaVVWVT2VUOlVPlUrlVHpVX/UAagoaoIWnIGoqGqqFp6BqGhqlhadgajoar4Wn4GoFGqBFpxBqJRqqRaaCRaxQapUalUWs0Gq1Gp9FrDBqjxqQxaywaq8amsWscGqfGpXFrPBqv9oF0BB1RR9KRdRQdVUfT0XUMHVPH0rF1HB1Xx9PxdQKdUCfSiXUSnVQn00En1yl0Sp1Kp9ZpdFqdTqfXGXRGnUln1ll0Vp1NZ9c5dE6dS+fWeXRe/QMZCAaigWQgGygGqoFmoBsYBqaBZWAbOAaugWfgGwQGoUFkEBskBqlBZoBBblAYlAaVQW3QGLQGnUFvMBiMBpPBbLAYrAabwW5wGJwGl8Ft8Bi8xgcyEUxEE8lENlFMVBPNRDcxTEwTy8Q2cUxcE8/ENwlMQpPIJDZJTFKTzAST3KQwKU0qk9qkMWlNOpPeZDAZTSaT2WQxWU02k93kMDlNLpPb5DF5zQ9kIViIFpKFbKFYqBaahW5hWJgWloVt4Vi4Fp6FbxFYhBaRRWyRWKQWmQUWuUVhUVpUFrVFY9FadBa9xWAxWkwWs8VisVpsFrvFYXFaXBa3xWPxWh/IRrARbSQb2UaxUW00G93GsDFtLBvbxrFxbTwb3yawCW0im9gmsUltMhtscpvCprSpbGqbxqa16Wx6m8FmtJlsZpvFZrXZbHabw+a0uWxum8fmtT+Qg+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+qwOewOh8PpcDncDo/D63wgF8FFdJFcZBfFRXXRXHQXw8V0sVxsF8fFdfFcfJfAJXSJXGKXxCV1yVxwyV0Kl9KlcqldGpfWpXPpXQaX0WVymV0Wl9Vlc9ldDpfT5XK5XR6X1/1AHoKH6CF5yB6Kh+qheegehofpYXnYHo6H6+F5+B6BR+gRecQeiUfqkXngkXsUHqVH5VF7NB6tR+fRewweo8fkMXssHqvH5rF7HB6nx+Vxezwer/eBfAQf0UfykX0UH9VH89F9DB/Tx/KxfRwf18fz8X0Cn9An8ol9Ep/UJ/PBJ/cpfEqfyqf2aXxan86n9xl8Rp/JZ/ZZfFafzWf3OXxOn8vn9nl8Xv8DBQgBYoAUIAcoAWqAFqAHGAFmgBVgBzgBboAX4AcEAWFAFBAHJAFpQBZAQB5QBJQBVUAd0AS0AV1AHzAEjAFTwBywBKwBW8AecAScAVfAHfAEvMEHChFCxBApRA5RQtQQLUQPMULMECvEDnFC3BAvxA8JQsKQKCQOSULSkCyEkDykCClDqpA6pAlpQ7qQPmQIGUOmkDlkCVlDtpA95Ag5Q66QO+QJecMPFCFEiBFShByhRKgRWoQeYUSYEVaEHeFEuBFehB8RRIQRUUQckUSkEVkEEXlEEVFGVBF1RBPRRnQRfcQQMUZMEXPEErFGbBF7xBFxRlwRd8QT8UYfKEaIEWOkGDlGiVFjtBg9xogxY6wYO8aJcWO8GD8miAljopg4JolJY7IYYvKYIqaMqWLqmCamjeli+pghZoyZYuaYJWaN2WL2mCPmjLli7pgn5o0/UIKQICZICXKCkqAmaAl6gpFgJlgJdoKT4CZ4CX5CkBAmRAlxQpKQJmQJJOQJRUKZUCXUCU1Cm9Al9AlDwpgwJcwJS8KasCXsCUfCmXAl3AlPwpt8oBQhRUyRUuQUJUVN0VL0FCPFTLFS7BQnxU3xUvyUICVMiVLilCQlTclSSMlTipQypUqpU5qUNqVL6VOGlDFlSplTlpQ1ZUvZU46UM+VKuVOelDf9QBlChpghZcgZSoaaoWXoGUaGmWFl2BlOhpvhZfgZQUaYEWXEGUlGmpFlkJFnFBllRpVRZzQZbUaX0WcMGWPGlDFnLBlrxpaxZxwZZ8aVcWc8GW/2gf4PmO9k+EL6F4u/IPpFvy9sffHmCxTfCv+W5remvsXwjeJv+H3j5mvwr6W+Iv7K5nuo79d8mP8vhwJKqKCGBlrooIcBRphghgVW2GCHA0644IYH3u+8++UIOWKOlCPnKDlqjpaj5xg5Zo6VY+c4OW6Ol+PnBDlhTpQT5yQ5aU6W/3PynCKnzKly6pwmp83pcvqcIWfMmXLmnCVnzdly9pwj58y5cu6cJ+fNP1CBUCAWSAVygVKgFmgFeoFRYBZYBXaBU+AWeAV+QVAQFkQFcUFSkBZkxf/PyQuKgrKgKqgLmoK2oCvoC4aCsWAqmAuWgrVgK9gLjoKz4Cq4C56Ct/hAJUKJWCKVyCVKiVqileglRolZYpXYJU6JW+KV+CVBSVgSlcQlSUlakpX/T5WXFCVlSVVSlzQlbUlX0pcMJWPJVDKXLCVryVaylxwlZ8lVcpc8JW/5gSqECrFCqpArlAq1QqvQK4wKs8KqsCucCrfCq/ArgoqwIqqIK5KKtCKr/gsnrygqyoqqoq5oKtqKrqKvGCrGiqlirlgq1oqtYq84Ks6Kq+KueCre6gPVCDVijVQj1yg1ao1Wo9cYNWaNVWPXODVujVfj1wQ1YU1UE9ckNWlNVv+XcV5T1JQ1VU1d09S0NV1NXzPUjDVTzVyz1Kw1W81ec9ScNVfNXfPUvPUHahAaxAapQW5QGtQGrUFvMBrMBqvBbnAa3AavwW8IGsKGqCFuSBrShqz5b6q8oWgoG6qGuqFpaBu6hr5haBgbpoa5YWlYG7aGveFoOBuuhrvhaXibD9QitIgtUovcorSoLVqL3mK0mC1Wi93itLgtXovfErSELVFL3JK0pC1Z+9/ieUvRUrZULXVL09K2dC19y9Aytkwtc8vSsrZsLXvL0XK2XC13y9Pyth+oQ+gQO6QOuUPpUDu0Dr3D6DA7rA67w+lwO7wOvyPoCDuijrgj6Ug7su5/4OQdRUfZUXXUHU1H29F19B1Dx9gxdcwdS8fasXXsHUfH2XF13B1Px9t9oB6hR+yReuQepUft0Xr0HqPH7LF67B6nx+3xevyeoCfsiXrinqQn7cn6//GX9xQ9ZU/VU/c0PW1P19P3DD1jz9Qz9yw9a8/Ws/ccPWfP1XP3PD1v/4EGhAFxQBqQB5QBdUAb0AeMAXPAGrAHnAF3wBvwB4KBcCAaiAeSgXQgG/6HcT5QDJQD1UA90Ay0A91APzAMjAPTwDywDKwD28A+cAycA9fAPfAMvMMHGhFGxBFpRB5RRtQRbUQfMUbMEWvEHnFG3BFvxLine truncated
|
||||||
|
{"method": "GET", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DYour%2Bavatar%2Bhas%2Bbeen%2Bupdated."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/settings/avatar", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 4377", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarykD1bO7T1SED3rJ5v", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarykD1bO7T1SED3rJ5v\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAQJ0lEQVR4nBXXrbpFUBSG0V0URVEURVEURVEURVEURVEURXmLoiiKoiiKoiiKoiiKoiiKoriJ47iC8Vjz55u/3w/hh/hD+iH/UH6oP7Qf+g/jh/nD+mH/cH64P7wf/o/gR/gj+hH/SH6kP7If/Mh/FD/KH9WP+kfzo/3R/eh/DD/GH9OP+cfyY/2x/dh/HD/OH9eP+8fz4/3x+wkIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisApvALnAInAKXwC3wCLzCBxIRREQRSUQWUURUEU1EFzFETBFLxBZxRFwRT8QXCURCkUgkFklEUpFMBJFcpBApRSqRWqQRaUU6kV5kEBlFJpFZZBFZRTaRXeQQOUUukVvkEXnFDyQhSIgSkoQsoUioEpqELmFImBKWhC3hSLgSnoQvEUiEEpFELJFIpBKZBBK5RCFRSlQStUQj0Up0Er3EIDFKTBKzxCKxSmwSu8QhcUpcErfEI/FKH0hGkBFlJBlZRpFRZTQZXcaQMWUsGVvGkXFlPBlfJpAJZSKZWCaRSWUyGWRymUKmlKlkaplGppXpZHqZQWaUmWRmmUVmldlkdplD5pS5ZG6ZR+aVP5CCoCAqSAqygqKgKmgKuoKhYCpYCraCo+AqeAq+QqAQKkQKsUKikCpkCijkCoVCqVAp1AqNQqvQKfQKg8KoMCnMCovCqrAp7AqHwqlwKdwKj8KrfCAVQUVUkVRkFUVFVdFUdBVDxVSxVGwVR8VV8VR8lUAlVIlUYpVEJVXJVFDJVQqVUqVSqVUalValU+lVBpVRZVKZVRaVVWVT2VUOlVPlUrlVHpVX/UAagoaoIWnIGoqGqqFp6BqGhqlhadgajoar4Wn4GoFGqBFpxBqJRqqRaaCRaxQapUalUWs0Gq1Gp9FrDBqjxqQxaywaq8amsWscGqfGpXFrPBqv9oF0BB1RR9KRdRQdVUfT0XUMHVPH0rF1HB1Xx9PxdQKdUCfSiXUSnVQn00En1yl0Sp1Kp9ZpdFqdTqfXGXRGnUln1ll0Vp1NZ9c5dE6dS+fWeXRe/QMZCAaigWQgGygGqoFmoBsYBqaBZWAbOAaugWfgGwQGoUFkEBskBqlBZoBBblAYlAaVQW3QGLQGnUFvMBiMBpPBbLAYrAabwW5wGJwGl8Ft8Bi8xgcyEUxEE8lENlFMVBPNRDcxTEwTy8Q2cUxcE8/ENwlMQpPIJDZJTFKTzAST3KQwKU0qk9qkMWlNOpPeZDAZTSaT2WQxWU02k93kMDlNLpPb5DF5zQ9kIViIFpKFbKFYqBaahW5hWJgWloVt4Vi4Fp6FbxFYhBaRRWyRWKQWmQUWuUVhUVpUFrVFY9FadBa9xWAxWkwWs8VisVpsFrvFYXFaXBa3xWPxWh/IRrARbSQb2UaxUW00G93GsDFtLBvbxrFxbTwb3yawCW0im9gmsUltMhtscpvCprSpbGqbxqa16Wx6m8FmtJlsZpvFZrXZbHabw+a0uWxum8fmtT+Qg+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+qwOewOh8PpcDncDo/D63wgF8FFdJFcZBfFRXXRXHQXw8V0sVxsF8fFdfFcfJfAJXSJXGKXxCV1yVxwyV0Kl9KlcqldGpfWpXPpXQaX0WVymV0Wl9Vlc9ldDpfT5XK5XR6X1/1AHoKH6CF5yB6Kh+qheegehofpYXnYHo6H6+F5+B6BR+gRecQeiUfqkXngkXsUHqVH5VF7NB6tR+fRewweo8fkMXssHqvH5rF7HB6nx+Vxezwer/eBfAQf0UfykX0UH9VH89F9DB/Tx/KxfRwf18fz8X0Cn9An8ol9Ep/UJ/PBJ/cpfEqfyqf2aXxan86n9xl8Rp/JZ/ZZfFafzWf3OXxOn8vn9nl8Xv8DBQgBYoAUIAcoAWqAFqAHGAFmgBVgBzgBboAX4AcEAWFAFBAHJAFpQBZAQB5QBJQBVUAd0AS0AV1AHzAEjAFTwBywBKwBW8AecAScAVfAHfAEvMEHChFCxBApRA5RQtQQLUQPMULMECvEDnFC3BAvxA8JQsKQKCQOSULSkCyEkDykCClDqpA6pAlpQ7qQPmQIGUOmkDlkCVlDtpA95Ag5Q66QO+QJecMPFCFEiBFShByhRKgRWoQeYUSYEVaEHeFEuBFehB8RRIQRUUQckUSkEVkEEXlEEVFGVBF1RBPRRnQRfcQQMUZMEXPEErFGbBF7xBFxRlwRd8QT8UYfKEaIEWOkGDlGiVFjtBg9xogxY6wYO8aJcWO8GD8miAljopg4JolJY7IYYvKYIqaMqWLqmCamjeli+pghZoyZYuaYJWaN2WL2mCPmjLli7pgn5o0/UIKQICZICXKCkqAmaAl6gpFgJlgJdoKT4CZ4CX5CkBAmRAlxQpKQJmQJJOQJRUKZUCXUCU1Cm9Al9AlDwpgwJcwJS8KasCXsCUfCmXAl3AlPwpt8oBQhRUyRUuQUJUVN0VL0FCPFTLFS7BQnxU3xUvyUICVMiVLilCQlTclSSMlTipQypUqpU5qUNqVL6VOGlDFlSplTlpQ1ZUvZU46UM+VKuVOelDf9QBlChpghZcgZSoaaoWXoGUaGmWFl2BlOhpvhZfgZQUaYEWXEGUlGmpFlkJFnFBllRpVRZzQZbUaX0WcMGWPGlDFnLBlrxpaxZxwZZ8aVcWc8GW/2gf4PmO9k+EL6F4u/IPpFvy9sffHmCxTfCv+W5remvsXwjeJv+H3j5mvwr6W+Iv7K5nuo79d8mP8vhwJKqKCGBlrooIcBRphghgVW2GCHA0644IYH3u+8++UIOWKOlCPnKDlqjpaj5xg5Zo6VY+c4OW6Ol+PnBDlhTpQT5yQ5aU6W/3PynCKnzKly6pwmp83pcvqcIWfMmXLmnCVnzdly9pwj58y5cu6cJ+fNP1CBUCAWSAVygVKgFmgFeoFRYBZYBXaBU+AWeAV+QVAQFkQFcUFSkBZkxf/PyQuKgrKgKqgLmoK2oCvoC4aCsWAqmAuWgrVgK9gLjoKz4Cq4C56Ct/hAJUKJWCKVyCVKiVqileglRolZYpXYJU6JW+KV+CVBSVgSlcQlSUlakpX/T5WXFCVlSVVSlzQlbUlX0pcMJWPJVDKXLCVryVaylxwlZ8lVcpc8JW/5gSqECrFCqpArlAq1QqvQK4wKs8KqsCucCrfCq/ArgoqwIqqIK5KKtCKr/gsnrygqyoqqoq5oKtqKrqKvGCrGiqlirlgq1oqtYq84Ks6Kq+KueCre6gPVCDVijVQj1yg1ao1Wo9cYNWaNVWPXODVujVfj1wQ1YU1UE9ckNWlNVv+XcV5T1JQ1VU1d09S0NV1NXzPUjDVTzVyz1Kw1W81ec9ScNVfNXfPUvPUHahAaxAapQW5QGtQGrUFvMBrMBqvBbnAa3AavwW8IGsKGqCFuSBrShqz5b6q8oWgoG6qGuqFpaBu6hr5haBgbpoa5YWlYG7aGveFoOBuuhrvhaXibD9QitIgtUovcorSoLVqL3mK0mC1Wi93itLgtXovfErSELVFL3JK0pC1Z+9/ieUvRUrZULXVL09K2dC19y9Aytkwtc8vSsrZsLXvL0XK2XC13y9Pyth+oQ+gQO6QOuUPpUDu0Dr3D6DA7rA67w+lwO7wOvyPoCDuijrgj6Ug7su5/4OQdRUfZUXXUHU1H29F19B1Dx9gxdcwdS8fasXXsHUfH2XF13B1Px9t9oB6hR+yReuQepUft0Xr0HqPH7LF67B6nx+3xevyeoCfsiXrinqQn7cn6//GX9xQ9ZU/VU/c0PW1P19P3DD1jz9Qz9yw9a8/Ws/ccPWfP1XP3PD1v/4EGhAFxQBqQB5QBdUAb0AeMAXPAGrAHnAF3wBvwB4KBcCAaiAeSgXQgG/6HcT5QDJQD1UA90Ay0A91APzAMjAPTwDywDKwD28A+cAycA9fAPfAMvMMHGhFGxBFpRB5RRtQRbUQfMUbLine truncated
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DThe%2Brepository%2Bavatar%2Bhas%2Bbeen%2Bupdated."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings/collaboration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/search_candidates?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "X-Requested-With: XMLHttpRequest", "Referer: http://example.com/alice/tools/settings/collaboration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/settings/collaboration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/search_candidates?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/applications", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/users", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/users?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/emails", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/emails?q=example.com", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/orgs", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/repos", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/repos?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/auths", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/config", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/config/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/monitor/stats", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/monitor/queue", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/monitor/cron", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/hooks", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/gogs.ico", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/feishu.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/slack.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/discord.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/telegram.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/dingtalk.ico", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/msteams.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/wechatwork.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/packagist.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/img/sourcehut.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/notices", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/users/2", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/users/2", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/users/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/account", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/appearance", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/security", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/keys", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/notifications", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=activity", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice?tab=stars", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/org/acme/dashboard", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/acme", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings/hooks", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/settings/units", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo/migrate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=b7d8a1e4a0871671; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=b7d8a1e4a0871671; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=890deb73026afc5f; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/compare/main...python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 764", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryaGATihSdXZHYz0ae", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMove to python3\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nPorts the scripts.\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"label_ids\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"milestone_id\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"project_id\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"assignee_ids\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"redirect_after_creation\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae--\r\n"}
|
||||||
|
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 37", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fpulls%2F2"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls/2", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues/2/content-history/overview", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/pull-request-merge-form.08cfd3a0.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/pull-request-merge-form.8413f096.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls/2", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls/2/commits", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/pulls/2/files", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/diff-commit-selector.17df8f29.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/pulls/2/files", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/diff-commit-selector.302a470f.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2/files", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 142", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "name=alice&full_name=Alice+Example&pronouns=&biography=Writes+build+tools.&website=https%3A%2F%2Fexample.com&location=Springfield&visibility=0"}
|
||||||
|
{"method": "POST", "target": "/admin/users/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 185", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/new", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "_autofill_dummy_username=&_autofill_dummy_password=&login_type=0-0&visibility=0&login_name=&user_name=carol&email=carol%40example.com&password=Qe5-tVb7-Mn3c-Kj8y&must_change_password=on"}
|
||||||
|
{"method": "GET", "target": "/admin/users/4", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/new", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US; flash=success%3DThe%2Buser%2Baccount%2B%2522carol%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/d4766e3f21c67b7c786f012d910fa54f?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/users/4", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/admin/users/2/edit", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/admin/users/2/edit", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 265", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/2/edit", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "_autofill_dummy_username=&_autofill_dummy_password=&user_name=bob&login_type=0-0&visibility=0&login_name=&full_name=Bob+Example&pronouns=&email=bob%40example.com&password=&language=en-US&website=&location=&max_repo_creation=-1&active=on&allow_create_organization=on"}
|
||||||
|
{"method": "GET", "target": "/admin/users/2", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/2/edit", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US; flash=success%3DThe%2Buser%2Baccount%2Bhas%2Bbeen%2Bupdated."], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 241", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=34fdf904aa90270d; lang=en-US"], "body": "name=build-token&repo_search=&page=1&scope=write%3Aactivitypub&scope=write%3Aadmin&scope=write%3Aissue&scope=write%3Amisc&scope=write%3Anotification&scope=write%3Aorganization&scope=write%3Apackage&scope=write%3Arepository&scope=write%3Auser"}
|
||||||
|
{"method": "POST", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 254", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=8ed5d13514629cf4; lang=en-US"], "body": "name=build-token&resource=all&repo_search=&page=1&scope=write%3Aactivitypub&scope=write%3Aadmin&scope=write%3Aissue&scope=write%3Amisc&scope=write%3Anotification&scope=write%3Aorganization&scope=write%3Apackage&scope=write%3Arepository&scope=write%3Auser"}
|
||||||
|
{"method": "GET", "target": "/user/settings/applications", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=8ed5d13514629cf4; lang=en-US; flash=info%3D518efde244d57864ef12487cdec73aa828e5deaf%26success%3DYour%2Bnew%2Btoken%2Bhas%2Bbeen%2Bgenerated.%2BCopy%2Bit%2Bnow%2Bas%2Bit%2Bwill%2Bnot%2Bbe%2Bshown%2Bagain."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/version", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/user", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/settings/api", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/search?q=python3&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/users/search?q=bob", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/users/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/issues/search?q=ls%20-la&type=issues", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/issues/search?q=build", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=all&q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/branches/python3-migration", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/tags", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents?ref=main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/scripts/install-script.sh?ref=main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/raw/bin/docker-entrypoint", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/media/src/process.env.d.ts", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/commits?path=scripts/install-script.sh&limit=5", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/commits?sha=python3-migration", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/git/trees/main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/labels", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/collaborators", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/hooks", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/admin/users", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/admin/orgs", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/orgs/acme", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/users/alice/repos", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/notifications", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/labels", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 68"], "body": "{\"name\":\"bug\",\"color\":\"#ee0701\",\"description\":\"Something is broken\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 83"], "body": "{\"title\":\"Document the python3 setup\",\"body\":\"Say which version the scripts need.\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/comments", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 40"], "body": "{\"body\":\"The setup notes cover it now.\"}"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/issues/3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 18"], "body": "{\"state\":\"closed\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/assets", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------c8PTgaGzJM0bzMXgzs4jFA"], "body": "--------------------------c8PTgaGzJM0bzMXgzs4jFA\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------c8PTgaGzJM0bzMXgzs4jFA--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/assets?name=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------8vBtFvaNGea9428ByqN6zh"], "body": "--------------------------8vBtFvaNGea9428ByqN6zh\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------8vBtFvaNGea9428ByqN6zh--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 69"], "body": "{\"tag_name\":\"v1.2.0\",\"name\":\"Version 1.2.0\",\"body\":\"Smaller images.\"}"}
|
||||||
|
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases/tags/v1.2.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases/3/assets?name=install-script.sh", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------a8C2mRAVkCsfebNVoTuH9d"], "body": "--------------------------a8C2mRAVkCsfebNVoTuH9d\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------a8C2mRAVkCsfebNVoTuH9d--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases/3/assets", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------bwR0eGsF72IMLlKpnzjarK"], "body": "--------------------------bwR0eGsF72IMLlKpnzjarK\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------bwR0eGsF72IMLlKpnzjarK--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/scripts/powershell-build.ps1", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 100"], "body": "{\"content\":\"V3JpdGUtSG9zdCAiYnVpbGQiCg==\",\"message\":\"Add a PowerShell build script\",\"branch\":\"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 59"], "body": "{\"new_branch_name\":\"python3-docs\",\"old_branch_name\":\"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 96"], "body": "{\"title\":\"Add Dockerfile\",\"head\":\"feat/docker-support\",\"base\":\"main\",\"body\":\"Builds the image.\"}"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 87"], "body": "{\"description\":\"Build scripts, notes and a Dockerfile\",\"website\":\"https://example.com\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/forks", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 23"], "body": "{\"organization\":\"acme\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/admin/users", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 107"], "body": "{\"username\":\"dave\",\"email\":\"dave@example.com\",\"password\":\"Lp6-xRw4-Bq9z-Hs3d\",\"must_change_password\":false}"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/admin/users/dave", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 80"], "body": "{\"login_name\":\"dave\",\"full_name\":\"Dave Example\",\"password\":\"Yt2-cNm8-Fg5k-Wa7e\"}"}
|
||||||
|
{"method": "DELETE", "target": "/api/v1/repos/alice/tools/branches/python3-docs", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/packages/alice/generic/tools/1.2.0/tools-linux-amd64.tar.gz", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/packages/alice?q=tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/api/v1/packages/alice?type=generic&q=install-script", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
|
||||||
|
{"method": "PUT", "target": "/api/packages/alice/generic/tools/1.2.0/tools-linux-amd64.tar.gz", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: curl/8.17.0", "Accept: */*", "Content-Length: 41"], "body": "Steps to reproduce: run the build twice.\n"}
|
||||||
|
{"method": "PUT", "target": "/api/packages/alice/generic/install-script/1.0.0/install-script.sh", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: curl/8.17.0", "Accept: */*", "Content-Length: 41"], "body": "Steps to reproduce: run the build twice.\n"}
|
||||||
|
{"method": "GET", "target": "/v2/", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fpython3-app%3Apush%2Cpull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/python3-app/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/v2/alice/python3-app/blobs/uploads/", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/python3-app/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "PATCH", "target": "/v2/alice/python3-app/blobs/uploads/iljdbd0gxw2zdibpfrajzu", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 144", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": "H4sIAAAAAAAE/+zRQQoCIRTG8bfuFJ6g+Wu+8TwughYDymTQ8YOaNhK0More+y1E3ej35VonGTwASVV4TL8CiNeQFB9jQvAcohenQ1+1uZxbXp2TtZS2Hb307v5H5Vqn03FZyr5dR/0PYI6x7/25B+au/xDw4vhEpn/e/7373fiYjTHGfJfbAH2FcNwACgAA"}
|
||||||
|
{"method": "PUT", "target": "/v2/alice/python3-app/blobs/uploads/iljdbd0gxw2zdibpfrajzu?digest=sha256%3A1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "PATCH", "target": "/v2/alice/python3-app/blobs/uploads/1eu6yk4t4l8lmbsedmyw9l", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 233", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": "{\"architecture\":\"\",\"created\":\"0001-01-01T00:00:00Z\",\"history\":[{\"created\":\"0001-01-01T00:00:00Z\"}],\"os\":\"\",\"rootfs\":{\"type\":\"layers\",\"diff_ids\":[\"sha256:19e75beae854ceb749acf78607bea3b2c0b04b74bbcc28f15e846683cf8e0b9d\"]},\"config\":{}}"}
|
||||||
|
{"method": "PUT", "target": "/v2/alice/python3-app/blobs/uploads/1eu6yk4t4l8lmbsedmyw9l?digest=sha256%3Af4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "PUT", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 423", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/vnd.docker.distribution.manifest.v2+json", "Accept-Encoding: gzip"], "body": "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":233,\"digest\":\"sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d\"},\"layers\":[{\"mediaType\":\"application/vnd.docker.image.rootfs.diff.tar.gzip\",\"size\":144,\"digest\":\"sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068\"}]}"}
|
||||||
|
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fpython3-app%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apush%2Cpull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/docker-build/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "HEAD", "target": "/v2/alice/docker-build/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apush%2Cpull&scope=repository%3Aalice%2Fpython3-app%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/v2/alice/docker-build/blobs/uploads/?from=alice%2Fpython3-app&mount=sha256%3Af4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d&origin=example.com", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/v2/alice/docker-build/blobs/uploads/?from=alice%2Fpython3-app&mount=sha256%3A1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068&origin=example.com", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "PUT", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 423", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/vnd.docker.distribution.manifest.v2+json", "Accept-Encoding: gzip"], "body": "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":233,\"digest\":\"sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d\"},\"layers\":[{\"mediaType\":\"application/vnd.docker.image.rootfs.diff.tar.gzip\",\"size\":144,\"digest\":\"sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068\"}]}"}
|
||||||
|
{"method": "GET", "target": "/v2/alice/python3-app/tags/list?n=1000", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/alice/python3-app/tags/list?last=1.0&n=1000", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/alice/docker-build/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/v2/alice/docker-build/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 473"], "body": "0011command=fetch001aagent=git/2.49.1-Linux0016object-format=sha10001000dthin-pack000fno-progress000dofs-delta0032want 0992a6293c77c3ef1f9a8d37b774deddcbef2e23\n0032want 4b5ec1aab65272b63b5b1f39bfc2c14684c063a0\n0032want 0992a6293c77c3ef1f9a8d37b774deddcbef2e23\n0032want f7ffa60396d99f61315a5a9a225fc32eb6228fdb\n0032want bbe5223a9a4f84e7c3d7206f4bdd062239e8309b\n0032want 15e1fb0f45ffdb9d4d07ccf23b8971fca09ab2ca\n0032want 15e1fb0f45ffdb9d4d07ccf23b8971fca09ab2ca\n0009done\n0000"}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 519"], "body": "MDBiZDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgY2M0ZDVlMDVlMWIzMTg0ZjQ3MTZlZDE1OGQyNWI4Y2ZhZTM4NzU0MyByZWZzL2hlYWRzL2RvY2tlci1idWlsZAAgcmVwb3J0LXN0YXR1cy12MiBzaWRlLWJhbmQtNjRrIHF1aWV0IG9iamVjdC1mb3JtYXQ9c2hhMSBhZ2VudD1naXQvMi40OS4xLUxpbnV4MDAwMFBBQ0sAAAACAAAABJENeJyVi10KwjAQBt9zirwLkp+2mwURffcSm80XLLS2tBE8vvUIzsvAwLQNsIQsA+D7mAjJkfOSMVTppEKZNUCVAwezyoZXs445yBA4KpFGVF9ZUomUibqCUjSjBoRo5N2ey2bv06iwF/npho/M64SzLvPVeuIupuD73p7cgTnqPLaGvybzGPdm6zhhN18fLT8K8AKeTXt+RMTzqiOmBNVYae5fWGAYnHice8b0jGmDCaOIb2jBcr50ZZ79x/sfSmXMOBZVkFs42YNRDgDQmAyf9gKBbcdPQPqMH4Wr8ShABeE9G0Q2EXic62RcwzjBRCYns7hErziDQav39///EVurZi0SCOP5u3X/31Yu/om6MQA3nhDoN3icyylW0M1J5AIACRECBP3ERXDJ4M4Guqo/ktYk+wdQ6W4g"}
|
||||||
|
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 193"], "body": "00bdcc4d5e05e1b3184f4716ed158d25b8cfae387543 0000000000000000000000000000000000000000 refs/heads/docker-build\u0000 report-status-v2 side-band-64k quiet object-format=sha1 agent=git/2.49.1-Linux0000"}
|
||||||
|
{"method": "GET", "target": "/alice/powershell-scripts.git/info/refs?service=git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache", "Git-Protocol: version=2"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/repos", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/explore/users?q=alice", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/users?q=alice", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/raw/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/issues", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases/tag/v1.1.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages/generic/tools/1.2.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages/container/python3-app/1.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases/download/v1.1.0/tools-linux-amd64.tar.gz", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Host: example.com", "Connection: keep-alive"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/releases/download/v1.2.0/install-script.sh", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Host: example.com", "Connection: keep-alive"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 43", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml"], "body": "user_name=alice&password=Hv4-mQz8-Lr2x-Tp9w"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages/generic/install-script/1.0.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages?q=python3&type=container", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/-/packages?q=python3&type=container", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/-/packages/container/python3-app/1.0/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/context-popup.21e2b8da.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 168", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "last_commit=0992a6293c77c3ef1f9a8d37b774deddcbef2e23&commit_summary=Remove+.htaccess&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DFile%2B%2522.htaccess%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/src/branch/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/src/branch/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 186", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "last_commit=cf1f6e0dcb6e2685ce4ffcc1225d31c65cd64d26&commit_summary=Remove+scripts%2Finstall-script.sh&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main/scripts", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DFile%2B%2522scripts%252Finstall-script.sh%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 28", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/src/branch/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "new_branch_name=docker-build"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/docker-build/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/src/branch/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2B%2522docker-build%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/delete?name=docker-build&page=1", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 44", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylgvDcSqmeTmS8iNm", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "------WebKitFormBoundarylgvDcSqmeTmS8iNm--\r\n"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches?page=1", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2B%2522docker-build%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/settings/rename_branch", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 52", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "from=feat%2Fdocker-support&to=feat%2Fpython3-support"}
|
||||||
|
{"method": "GET", "target": "/alice/tools/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2Bfeat%252Fdocker-support%2Bwas%2Bsuccessfully%2Brenamed%2Bto%2Bfeat%252Fpython3-support."], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "POST", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 180", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": "title=Python3+setup&content=Install+python3%2C+or+powershell+on+Windows.+Run+ls+-la+to+check+that+.htaccess+and+docker-compose.yml+are+there%2C+then+run+install-script.sh.&message="}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=ls%20-la", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/powershell-scripts", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/explore/repos?q=powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/powershell-scripts", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/powershell-scripts", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/assets/js/context-popup.21e2b8da.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/alice/tools/src/branch/python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
|
||||||
|
{"method": "GET", "target": "/acme?q=python3&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# before the rules it switches off, so this file is loaded before the Core Rule
|
# before the rules it switches off, so this file is loaded before the Core Rule
|
||||||
# Set's rules. Each comment names a request a rule refused. An exclusion on a
|
# Set's rules. Each comment names a request a rule refused. An exclusion on a
|
||||||
# field of a body applies only when the query has no parameter of that name, so
|
# field of a body applies only when the query has no parameter of that name, so
|
||||||
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# it matters only while SWW_WAF_BODY_LIMIT is set.
|
||||||
#
|
#
|
||||||
# The apps encrypt the names, notes, addresses and file names of vault items,
|
# The apps encrypt the names, notes, addresses and file names of vault items,
|
||||||
# folders, collections and sends before they send them. These values pass
|
# folders, collections and sends before they send them. These values pass
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const vaultwardenThreshold = 5
|
const vaultwardenThreshold = 5
|
||||||
|
|
||||||
// Paths of Vaultwarden's API and admin page.
|
// Paths of Vaultwarden's API and admin page.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||||
# of a body applies only when the query has no parameter of that name, so it
|
# of a body applies only when the query has no parameter of that name, so it
|
||||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# matters only while SWW_WAF_BODY_LIMIT is set.
|
||||||
|
|
||||||
# WordPress takes a call to its REST API at any of four addresses:
|
# WordPress takes a call to its REST API at any of four addresses:
|
||||||
# /wp-json/wp/v2/posts/1
|
# /wp-json/wp/v2/posts/1
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
|||||||
// FileParams are what OpenFile needs.
|
// FileParams are what OpenFile needs.
|
||||||
type FileParams struct {
|
type FileParams struct {
|
||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWW_WAF_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -231,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWW_WAF_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package lookup looks up each client's AS number and country, through
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// SWW_WAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWW_WAF_LOOKUP_TIMEOUT).
|
// (SWW_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type busiest struct {
|
|||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
|
|||||||
+20
-22
@@ -62,8 +62,8 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWW_WAF_INSTANCE_NAME) as its label instance.
|
// (SWW_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
offences: counterVec("sww_offences_total",
|
offences: counterVec("sww_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
|
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_CORE_RULE_SET_MODE "+
|
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
@@ -120,19 +120,17 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
"went on without it, because GeoJS had not given it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("sww_state_file_writes_total",
|
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||||
"Writes of each state or cache file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||||
"Writes of each state or cache file that failed.", byFile),
|
"Writes of each state file that failed.", byFile),
|
||||||
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||||
"When each state or cache file was last written, in seconds since 1970.",
|
"When each state file was last written, in seconds since 1970.", byFile),
|
||||||
byFile),
|
|
||||||
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||||
"The size of each state or cache file, as it was last written.", byFile),
|
"The size of each state file, as it was last written.", byFile),
|
||||||
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||||
"Edits of each state or cache file taken in while running.", byFile),
|
"Edits of each state file taken in while running.", byFile),
|
||||||
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||||
"Edits of each state or cache file renamed to <name>.bad because they "+
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
"did not parse.",
|
|
||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,13 +214,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWW_WAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_remote_log_lines_sent_total",
|
Name: "sww_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
@@ -315,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWW_WAF_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -324,7 +322,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_reputation_daily_budget_remaining",
|
Name: "sww_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -365,8 +363,8 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_suppressed_total",
|
Name: "sww_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
@@ -444,13 +442,13 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
|
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWW_WAF_CORE_RULE_SET_MODE at mode.
|
// with SWW_WAF_MODE at mode.
|
||||||
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
|
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
|
||||||
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateFileWritten counts a write of the state or cache file name, of size
|
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||||
// bytes, that ended with err.
|
// that ended with err.
|
||||||
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
||||||
m.stateFileWrites.WithLabelValues(name).Inc()
|
m.stateFileWrites.WithLabelValues(name).Inc()
|
||||||
|
|
||||||
@@ -468,13 +466,13 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
m.stateFileSize.WithLabelValues(name).Set(float64(size))
|
m.stateFileSize.WithLabelValues(name).Set(float64(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditTakenIn counts an admin's edit of the state or cache file name
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
// taken in while sww runs.
|
// taken in while sww runs.
|
||||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditSetAside counts an admin's edit of the state or cache file name
|
// StateFileEditSetAside counts an admin's edit of the state file name
|
||||||
// renamed to name.bad because it did not parse.
|
// renamed to name.bad because it did not parse.
|
||||||
func (m *Metrics) StateFileEditSetAside(name string) {
|
func (m *Metrics) StateFileEditSetAside(name string) {
|
||||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// method" section of SPEC.md describes, which tell when a request takes
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// the client over a rate limit or a byte limit, and each client's history
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// since it was first seen. At most SWW_WAF_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// kept, in memory, and written to clients.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
@@ -157,7 +157,7 @@ type Request struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
// table holds at most maxClients clients (SWW_WAF_MAX_TRACKED_CLIENTS). Past
|
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
// it, the least recently seen client is dropped, with its history, and
|
||||||
// starts afresh if it comes back.
|
// starts afresh if it comes back.
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
func New(limits Limits, maxClients int) *Limiter {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
// tableSize is the most clients the tests' tables hold, the default of
|
// tableSize is the most clients the tests' tables hold, the default of
|
||||||
// SWW_WAF_MAX_TRACKED_CLIENTS.
|
// SWW_MAX_TRACKED_CLIENTS.
|
||||||
const tableSize = 20000
|
const tableSize = 20000
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package remotelog sends the lines sww writes on stdout to the
|
// Package remotelog sends the lines sww writes on stdout to the
|
||||||
// remote log endpoint, SWW_WAF_LOG_REMOTE_URL, as the "Request log" section
|
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
||||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||||
// or unreachable endpoint never holds up a request or stdout.
|
// or unreachable endpoint never holds up a request or stdout.
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The forms of SWW_WAF_LOG_REMOTE_URL, by its scheme.
|
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
||||||
const (
|
const (
|
||||||
SchemeUDP = "syslog+udp"
|
SchemeUDP = "syslog+udp"
|
||||||
SchemeTCP = "syslog+tcp"
|
SchemeTCP = "syslog+tcp"
|
||||||
@@ -56,18 +56,18 @@ const (
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is the endpoint (SWW_WAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||||
// SchemeTLS, a host and a port.
|
// SchemeTLS, a host and a port.
|
||||||
URL *url.URL
|
URL *url.URL
|
||||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
// Buffer is the most lines held while they wait to be sent
|
// Buffer is the most lines held while they wait to be sent
|
||||||
// (SWW_WAF_LOG_REMOTE_BUFFER).
|
// (SWW_LOG_REMOTE_BUFFER).
|
||||||
Buffer int
|
Buffer int
|
||||||
// Facility is the number of the records' syslog facility
|
// Facility is the number of the records' syslog facility
|
||||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
// (SWW_WAF_LOG_REMOTE_APP_NAME).
|
// (SWW_LOG_REMOTE_APP_NAME).
|
||||||
Facility int
|
Facility int
|
||||||
AppName string
|
AppName string
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed",
|
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
||||||
"error", err.Error(), "connecting_again_in", delay.String())
|
"error", err.Error(), "connecting_again_in", delay.String())
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
|||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" {
|
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
||||||
t.Fatalf("logged %q", line)
|
t.Fatalf("logged %q", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,37 +36,38 @@ var (
|
|||||||
"checks spent; none is made until the day ends at 00:00 UTC")
|
"checks spent; none is made until the day ends at 00:00 UTC")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Score is what AbuseIPDB said about a client, as abuseipdb-scores.json
|
// Score is what AbuseIPDB said about a client, as reputation.json holds
|
||||||
// holds it: the client, an IPv4 address or an IPv6 group, its abuse
|
// it: the client, an IPv4 address or an IPv6 group, its abuse confidence
|
||||||
// confidence score, from 0 to 100, and when AbuseIPDB answered.
|
// score, from 0 to 100, and when AbuseIPDB answered.
|
||||||
type Score struct {
|
type Score struct {
|
||||||
Client netip.Prefix `json:"client"`
|
Client netip.Prefix `json:"client"`
|
||||||
Score int64 `json:"score"`
|
Score int64 `json:"score"`
|
||||||
Fetched time.Time `json:"fetched"`
|
Fetched time.Time `json:"fetched"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChecksSpent is what abuseipdb-checks-spent.json keeps of the checks of
|
// Checks are what reputation.json keeps of the checks of clients with
|
||||||
// clients with AbuseIPDB: the day, in UTC, of the checks Spent counts,
|
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
|
||||||
// zero before the first.
|
// first, and the scores still in use.
|
||||||
type ChecksSpent struct {
|
type Checks struct {
|
||||||
Day civil.Date
|
Day civil.Date `json:"day,omitzero"`
|
||||||
Spent int
|
Spent int `json:"spent"`
|
||||||
|
Scores []Score `json:"scores"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
type AbuseIPDBParams struct {
|
type AbuseIPDBParams struct {
|
||||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||||
// the account's key (SWW_WAF_ABUSEIPDB_KEY).
|
// the account's key (SWW_ABUSEIPDB_KEY).
|
||||||
URL string
|
URL string
|
||||||
Key string
|
Key string
|
||||||
// MinScore is the least score that is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE),
|
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
||||||
// and DailyBudget the most checks made in a day, in UTC
|
// and DailyBudget the most checks made in a day, in UTC
|
||||||
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||||
MinScore int64
|
MinScore int64
|
||||||
DailyBudget int
|
DailyBudget int
|
||||||
// CacheTTL is how long a score is used after it was fetched
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -195,35 +196,35 @@ func (a *AbuseIPDB) BudgetLeft() int {
|
|||||||
return max(a.params.DailyBudget-a.spent, 0)
|
return max(a.params.DailyBudget-a.spent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns every score still in use, sorted by client, as
|
// Snapshot returns the checks spent and every score still in use, sorted
|
||||||
// abuseipdb-scores.json keeps them.
|
// by client, as reputation.json keeps them.
|
||||||
func (a *AbuseIPDB) Snapshot() []Score {
|
func (a *AbuseIPDB) Snapshot() Checks {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|
||||||
now := a.params.Now()
|
now := a.params.Now()
|
||||||
scores := make([]Score, 0, a.scores.Len())
|
checks := Checks{Day: a.day, Spent: a.spent, Scores: make([]Score, 0, a.scores.Len())}
|
||||||
|
|
||||||
for _, kept := range a.scores.Values() {
|
for _, kept := range a.scores.Values() {
|
||||||
if now.Sub(kept.Fetched) < a.params.CacheTTL {
|
if now.Sub(kept.Fetched) < a.params.CacheTTL {
|
||||||
scores = append(scores, kept)
|
checks.Scores = append(checks.Scores, kept)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
slices.SortFunc(scores, func(x, y Score) int {
|
slices.SortFunc(checks.Scores, func(x, y Score) int {
|
||||||
return x.Client.Compare(y.Client)
|
return x.Client.Compare(y.Client)
|
||||||
})
|
})
|
||||||
|
|
||||||
return scores
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps scores, read from abuseipdb-scores.json, in place of those
|
// Load keeps checks, read from reputation.json, in place of those it
|
||||||
// it keeps, but for the scores past maxVerdicts, those fetched longest
|
// keeps, but for the scores past maxVerdicts, those fetched longest ago.
|
||||||
// ago. One fetched CacheTTL ago or more is neither used nor written, as
|
// One fetched CacheTTL ago or more is neither used nor written, as for any
|
||||||
// for any score.
|
// score.
|
||||||
func (a *AbuseIPDB) Load(scores []Score) {
|
func (a *AbuseIPDB) Load(checks Checks) {
|
||||||
scores = slices.Clone(scores)
|
scores := slices.Clone(checks.Scores)
|
||||||
slices.SortStableFunc(scores, func(x, y Score) int {
|
slices.SortStableFunc(scores, func(x, y Score) int {
|
||||||
return x.Fetched.Compare(y.Fetched)
|
return x.Fetched.Compare(y.Fetched)
|
||||||
})
|
})
|
||||||
@@ -231,6 +232,7 @@ func (a *AbuseIPDB) Load(scores []Score) {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.day, a.spent = checks.Day, checks.Spent
|
||||||
a.scores.Purge()
|
a.scores.Purge()
|
||||||
|
|
||||||
for _, kept := range scores {
|
for _, kept := range scores {
|
||||||
@@ -238,24 +240,6 @@ func (a *AbuseIPDB) Load(scores []Score) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChecksSpent returns the checks spent, as abuseipdb-checks-spent.json
|
|
||||||
// keeps them.
|
|
||||||
func (a *AbuseIPDB) ChecksSpent() ChecksSpent {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
return ChecksSpent{Day: a.day, Spent: a.spent}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadChecksSpent keeps checks, read from abuseipdb-checks-spent.json, in
|
|
||||||
// place of the checks spent.
|
|
||||||
func (a *AbuseIPDB) LoadChecksSpent(checks ChecksSpent) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
a.day, a.spent = checks.Day, checks.Spent
|
|
||||||
}
|
|
||||||
|
|
||||||
// check checks client with AbuseIPDB by addr, one of its addresses, keeps
|
// check checks client with AbuseIPDB by addr, one of its addresses, keeps
|
||||||
// the score as client's, and notes the check as no longer under way. A
|
// the score as client's, and notes the check as no longer under way. A
|
||||||
// check that fails gives no score: it is counted, logged and raised as a
|
// check that fails gives no score: it is counted, logged and raised as a
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cloud.google.com/go/civil"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
@@ -180,13 +179,11 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
|
|||||||
t.Errorf("logged\n%s\nwant the budget used up", log.String())
|
t.Errorf("logged\n%s\nwant the budget used up", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restarted with what abuseipdb-scores.json and
|
// Restarted with what reputation.json keeps, it uses the scores, and
|
||||||
// abuseipdb-checks-spent.json keep, it uses the scores, and checks
|
// checks no client until the day ends.
|
||||||
// no client until the day ends.
|
|
||||||
restarted := &abuseIPDBStandIn{}
|
restarted := &abuseIPDBStandIn{}
|
||||||
again := newAbuseIPDB(restarted, p)
|
again := newAbuseIPDB(restarted, p)
|
||||||
again.Load(checker.Snapshot())
|
again.Load(checker.Snapshot())
|
||||||
again.LoadChecksSpent(checker.ChecksSpent())
|
|
||||||
|
|
||||||
wantScore(t, again, suspect, true, 100, true)
|
wantScore(t, again, suspect, true, 100, true)
|
||||||
wantBudgetLeft(t, again, 0)
|
wantBudgetLeft(t, again, 0)
|
||||||
@@ -227,9 +224,13 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
|
|||||||
time.Sleep(2 * time.Hour)
|
time.Sleep(2 * time.Hour)
|
||||||
wantBudgetLeft(t, checker, 0)
|
wantBudgetLeft(t, checker, 0)
|
||||||
|
|
||||||
want := civil.Date{Year: 2000, Month: 1, Day: 1}
|
encoded, err := json.Marshal(checker.Snapshot())
|
||||||
if day := checker.ChecksSpent().Day; day != want {
|
if err != nil {
|
||||||
t.Errorf("checks spent on %s, want on %s", day, want)
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(encoded), `"day":"2000-01-01"`) {
|
||||||
|
t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// At 00:00 UTC the budget is whole again.
|
// At 00:00 UTC the budget is whole again.
|
||||||
@@ -301,7 +302,7 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
|
|||||||
wantChecked(t, abuseIPDB, suspect, other)
|
wantChecked(t, abuseIPDB, suspect, other)
|
||||||
wantFailures(t, checker, 2)
|
wantFailures(t, checker, 2)
|
||||||
|
|
||||||
if scores := checker.Snapshot(); len(scores) != 0 {
|
if scores := checker.Snapshot().Scores; len(scores) != 0 {
|
||||||
t.Errorf("scores %+v, want none", scores)
|
t.Errorf("scores %+v, want none", scores)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +399,7 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
|
|||||||
|
|
||||||
for name, shown := range map[string]string{
|
for name, shown := range map[string]string{
|
||||||
"the log": log.String(), "the alerts": string(alerted),
|
"the log": log.String(), "the alerts": string(alerted),
|
||||||
"the metrics": scrapeMetrics(t, m), "abuseipdb-scores.json": string(kept),
|
"the metrics": scrapeMetrics(t, m), "reputation.json": string(kept),
|
||||||
} {
|
} {
|
||||||
if strings.Contains(shown, key) {
|
if strings.Contains(shown, key) {
|
||||||
t.Errorf("%s shows the key:\n%s", name, shown)
|
t.Errorf("%s shows the key:\n%s", name, shown)
|
||||||
@@ -461,12 +462,12 @@ func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) {
|
|||||||
Fetched: now.Add(-cacheTTL),
|
Fetched: now.Add(-cacheTTL),
|
||||||
}
|
}
|
||||||
|
|
||||||
checker.Load([]reputation.Score{stale, inUse})
|
checker.Load(reputation.Checks{Scores: []reputation.Score{stale, inUse}})
|
||||||
|
|
||||||
wantScore(t, checker, suspect, false, 100, true)
|
wantScore(t, checker, suspect, false, 100, true)
|
||||||
wantScore(t, checker, other, false, 0, false)
|
wantScore(t, checker, other, false, 0, false)
|
||||||
|
|
||||||
got := checker.Snapshot()
|
got := checker.Snapshot().Scores
|
||||||
if !reflect.DeepEqual(got, []reputation.Score{inUse}) {
|
if !reflect.DeepEqual(got, []reputation.Score{inUse}) {
|
||||||
t.Errorf("scores %+v, want only %+v", got, inUse)
|
t.Errorf("scores %+v, want only %+v", got, inUse)
|
||||||
}
|
}
|
||||||
@@ -480,7 +481,7 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T)
|
|||||||
p.Now = func() time.Time { return now }
|
p.Now = func() time.Time { return now }
|
||||||
checker := reputation.NewAbuseIPDB(p)
|
checker := reputation.NewAbuseIPDB(p)
|
||||||
|
|
||||||
// 100,001 scores, listed by client, as abuseipdb-scores.json lists them, each
|
// 100,001 scores, listed by client, as reputation.json lists them, each
|
||||||
// fetched a millisecond before the one before it: the last is one too
|
// fetched a millisecond before the one before it: the last is one too
|
||||||
// many.
|
// many.
|
||||||
const count = 100001
|
const count = 100001
|
||||||
@@ -496,9 +497,9 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T)
|
|||||||
addr = addr.Next()
|
addr = addr.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
checker.Load(scores)
|
checker.Load(reputation.Checks{Scores: scores})
|
||||||
|
|
||||||
got := checker.Snapshot()
|
got := checker.Snapshot().Scores
|
||||||
if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
|
if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
|
||||||
slices.Contains(got, scores[count-1]) {
|
slices.Contains(got, scores[count-1]) {
|
||||||
t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
|
t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func crowdSecFailures() []crowdSecFailure {
|
|||||||
// other than its decision list, such as the one a redirect names, and that
|
// other than its decision list, such as the one a redirect names, and that
|
||||||
// the key is in none of what the fetches leave behind: log, the process
|
// the key is in none of what the fetches leave behind: log, the process
|
||||||
// log, the alerts waiting in queue, and the copies of lists, which
|
// log, the alerts waiting in queue, and the copies of lists, which
|
||||||
// lists.json keeps.
|
// reputation.json keeps.
|
||||||
func wantKeyNotShown(
|
func wantKeyNotShown(
|
||||||
t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
|
t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
|
||||||
) {
|
) {
|
||||||
@@ -326,7 +326,7 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing
|
|||||||
lists := start(t, e, crowdSecParams())
|
lists := start(t, e, crowdSecParams())
|
||||||
kept := lists.Snapshot()
|
kept := lists.Snapshot()
|
||||||
|
|
||||||
// Restarted half an hour later with what lists.json keeps, and
|
// Restarted half an hour later with what reputation.json keeps, and
|
||||||
// the engine down, the decision still bans, until the end it had at
|
// the engine down, the decision still bans, until the end it had at
|
||||||
// the fetch, half an hour on.
|
// the fetch, half an hour on.
|
||||||
time.Sleep(30 * time.Minute)
|
time.Sleep(30 * time.Minute)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var (
|
|||||||
errNotListing = errors.New("the answer is outside 127.0.0.0/8")
|
errNotListing = errors.New("the answer is outside 127.0.0.0/8")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verdict is what a zone said about a client, as dnsbl.json holds
|
// Verdict is what a zone said about a client, as reputation.json holds
|
||||||
// it: the zone, the client's address, whether the zone lists it, and when
|
// it: the zone, the client's address, whether the zone lists it, and when
|
||||||
// the zone answered.
|
// the zone answered.
|
||||||
type Verdict struct {
|
type Verdict struct {
|
||||||
@@ -53,15 +53,15 @@ type Verdict struct {
|
|||||||
// DNSBLParams are what NewDNSBL needs.
|
// DNSBLParams are what NewDNSBL needs.
|
||||||
type DNSBLParams struct {
|
type DNSBLParams struct {
|
||||||
// Zones are the DNSBL zones clients are asked about in
|
// Zones are the DNSBL zones clients are asked about in
|
||||||
// (SWW_WAF_DNSBL_ZONES).
|
// (SWW_DNSBL_ZONES).
|
||||||
Zones []string
|
Zones []string
|
||||||
// Resolver is the resolver they are asked through
|
// Resolver is the resolver they are asked through
|
||||||
// (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||||
// host's, as /etc/resolv.conf names it.
|
// host's, as /etc/resolv.conf names it.
|
||||||
Resolver netip.AddrPort
|
Resolver netip.AddrPort
|
||||||
// CacheTTL is how long a verdict is used after it was fetched
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them.
|
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
||||||
func (d *DNSBL) Zones() []string {
|
func (d *DNSBL) Zones() []string {
|
||||||
return slices.Clone(d.params.Zones)
|
return slices.Clone(d.params.Zones)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
// lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key
|
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
||||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||||
// passed since it was fetched. Each zone without one is asked about addr
|
// passed since it was fetched. Each zone without one is asked about addr
|
||||||
@@ -187,7 +187,7 @@ func (d *DNSBL) Failures(zone string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns every verdict still in use, sorted by client, then by
|
// Snapshot returns every verdict still in use, sorted by client, then by
|
||||||
// zone, as dnsbl.json lists them.
|
// zone, as reputation.json lists them.
|
||||||
func (d *DNSBL) Snapshot() []Verdict {
|
func (d *DNSBL) Snapshot() []Verdict {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
|
|
||||||
@@ -209,8 +209,8 @@ func (d *DNSBL) Snapshot() []Verdict {
|
|||||||
return verdicts
|
return verdicts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps verdicts, read from dnsbl.json, in place of those it
|
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||||
// keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and,
|
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
||||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||||
// more is neither used nor written, as for any verdict.
|
// more is neither used nor written, as for any verdict.
|
||||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ const (
|
|||||||
// zone and otherZone are the DNSBL zones the tests name.
|
// zone and otherZone are the DNSBL zones the tests name.
|
||||||
zone = "dnsbl.example"
|
zone = "dnsbl.example"
|
||||||
otherZone = "other.example"
|
otherZone = "other.example"
|
||||||
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
|
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
||||||
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||||
// can have Go's resolver wait for one server, so that it is the
|
// can have Go's resolver wait for one server, so that it is the
|
||||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
@@ -389,7 +389,7 @@ func TestVerdictsKeptAcrossARestart(t *testing.T) {
|
|||||||
t.Errorf("verdicts %+v, want %+v", kept, want)
|
t.Errorf("verdicts %+v, want %+v", kept, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restarted an hour later with what dnsbl.json keeps, it uses
|
// Restarted an hour later with what reputation.json keeps, it uses
|
||||||
// the verdicts, and asks the zone nothing, until the TTL has passed
|
// the verdicts, and asks the zone nothing, until the TTL has passed
|
||||||
// since they were fetched.
|
// since they were fetched.
|
||||||
time.Sleep(time.Hour)
|
time.Sleep(time.Hour)
|
||||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
|||||||
dnsbl := reputation.NewDNSBL(p)
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||||
// zone SWW_WAF_DNSBL_ZONES does not name.
|
// zone SWW_DNSBL_ZONES does not name.
|
||||||
inUse := reputation.Verdict{
|
inUse := reputation.Verdict{
|
||||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
@@ -446,7 +446,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
|
|||||||
p.Now = func() time.Time { return now }
|
p.Now = func() time.Time { return now }
|
||||||
dnsbl := reputation.NewDNSBL(p)
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
// 100,001 verdicts, listed by client, as dnsbl.json lists them,
|
// 100,001 verdicts, listed by client, as reputation.json lists them,
|
||||||
// each fetched a millisecond before the one before it: the last is one
|
// each fetched a millisecond before the one before it: the last is one
|
||||||
// too many.
|
// too many.
|
||||||
const count = 100001
|
const count = 100001
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and
|
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
||||||
// the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It
|
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
||||||
// keeps the last good copy of
|
// keeps the last good copy of
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||||
// today. The state package writes all of these to files and reads them
|
// today. The state package writes all of these to reputation.json and
|
||||||
// from there, so that a restart keeps them too.
|
// reads them from it, so that a restart keeps them too.
|
||||||
package reputation
|
package reputation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -54,7 +54,7 @@ var (
|
|||||||
"does not give an address or a netblock and a duration, such as 4h0m0s")
|
"does not give an address or a netblock and a duration, such as 4h0m0s")
|
||||||
)
|
)
|
||||||
|
|
||||||
// List is a list as lists.json holds it: the URL it is fetched from,
|
// List is a list as reputation.json holds it: the URL it is fetched from,
|
||||||
// when it was last tried, the fetch failed or not, and its last good copy:
|
// when it was last tried, the fetch failed or not, and its last good copy:
|
||||||
// when that was fetched, and its lines, as fetched, comment lines
|
// when that was fetched, and its lines, as fetched, comment lines
|
||||||
// included, both left out while no fetch of it has succeeded.
|
// included, both left out while no fetch of it has succeeded.
|
||||||
@@ -67,20 +67,20 @@ type List struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
|
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
||||||
// ASNLimitPercentURL the file of AS:percent lines
|
// ASNLimitPercentURL the file of AS:percent lines
|
||||||
// (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS).
|
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
||||||
TagLists []config.TagList
|
TagLists []config.TagList
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWW_WAF_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// Refresh is how long after a list was last fetched or tried it is
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
// fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||||
// list, which is fetched again crowdSecRefresh after.
|
// list, which is fetched again crowdSecRefresh after.
|
||||||
Refresh time.Duration
|
Refresh time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -106,7 +106,7 @@ type Lists struct {
|
|||||||
lists map[string]*list
|
lists map[string]*list
|
||||||
}
|
}
|
||||||
|
|
||||||
// list is one list: what lists.json keeps of it, its last try, zero
|
// list is one list: what reputation.json keeps of it, its last try, zero
|
||||||
// before the first, and its last good copy, what that copy says, and how
|
// before the first, and its last good copy, what that copy says, and how
|
||||||
// many fetches of it failed.
|
// many fetches of it failed.
|
||||||
type list struct {
|
type list struct {
|
||||||
@@ -155,7 +155,7 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the
|
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
||||||
// tag lists', then the CrowdSec decision list's.
|
// tag lists', then the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
@@ -175,7 +175,7 @@ func (l *Lists) URLs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||||
// the order SWW_WAF_BLOCKLIST_URLS names them.
|
// the order SWW_BLOCKLIST_URLS names them.
|
||||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -272,7 +272,7 @@ func (l *Lists) Failures(listURL string) int {
|
|||||||
|
|
||||||
// Run fetches each list once it is due, as due tells, until ctx is done. A
|
// Run fetches each list once it is due, as due tells, until ctx is done. A
|
||||||
// list never tried is fetched at once, and so is one that is due by its
|
// list never tried is fetched at once, and so is one that is due by its
|
||||||
// last try or copy read from lists.json.
|
// last try or copy read from reputation.json.
|
||||||
func (l *Lists) Run(ctx context.Context) {
|
func (l *Lists) Run(ctx context.Context) {
|
||||||
if len(l.lists) == 0 {
|
if len(l.lists) == 0 {
|
||||||
return
|
return
|
||||||
@@ -292,7 +292,7 @@ func (l *Lists) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns each list that has been tried, with its copy, if it
|
// Snapshot returns each list that has been tried, with its copy, if it
|
||||||
// has one, sorted by URL, as lists.json lists them.
|
// has one, sorted by URL, as reputation.json lists them.
|
||||||
func (l *Lists) Snapshot() []List {
|
func (l *Lists) Snapshot() []List {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ func (l *Lists) Snapshot() []List {
|
|||||||
return tried
|
return tried
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puts lists, read from lists.json, in place of the last tries
|
// Load puts lists, read from reputation.json, in place of the last tries
|
||||||
// and copies held. A list Params does not name is dropped. A copy with a
|
// and copies held. A list Params does not name is dropped. A copy with a
|
||||||
// line that parse refuses is an error, and then nothing changes.
|
// line that parse refuses is an error, and then nothing changes.
|
||||||
func (l *Lists) Load(lists []List) error {
|
func (l *Lists) Load(lists []List) error {
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const (
|
|||||||
dropURL = "https://lists.example/drop.txt"
|
dropURL = "https://lists.example/drop.txt"
|
||||||
torURL = "https://lists.example/tor.txt"
|
torURL = "https://lists.example/tor.txt"
|
||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
// refresh is the tests' SWW_WAF_BLOCKLIST_REFRESH, and cooldown their
|
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
||||||
// SWW_WAF_ALERT_COOLDOWN, longer than it.
|
// SWW_ALERT_COOLDOWN, longer than it.
|
||||||
refresh = 24 * time.Hour
|
refresh = 24 * time.Hour
|
||||||
cooldown = 48 * time.Hour
|
cooldown = 48 * time.Hour
|
||||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
lists := start(t, servers, params(torURL, dropURL))
|
lists := start(t, servers, params(torURL, dropURL))
|
||||||
|
|
||||||
// In the order SWW_WAF_BLOCKLIST_URLS names them.
|
// In the order SWW_BLOCKLIST_URLS names them.
|
||||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||||
})
|
})
|
||||||
@@ -260,7 +260,7 @@ func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing.
|
|||||||
lists.SetTransport(servers)
|
lists.SetTransport(servers)
|
||||||
|
|
||||||
// drop.txt was fetched an hour ago, and tor.txt a refresh ago, as
|
// drop.txt was fetched an hour ago, and tor.txt a refresh ago, as
|
||||||
// lists.json says at start.
|
// reputation.json says at start.
|
||||||
err := lists.Load([]reputation.List{
|
err := lists.Load([]reputation.List{
|
||||||
{URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}},
|
{URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}},
|
||||||
{URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}},
|
{URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}},
|
||||||
@@ -297,7 +297,7 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) {
|
|||||||
time.Sleep(refresh)
|
time.Sleep(refresh)
|
||||||
wantFetches(t, servers, 2)
|
wantFetches(t, servers, 2)
|
||||||
|
|
||||||
// Restarted with what lists.json keeps, it waits a refresh
|
// Restarted with what reputation.json keeps, it waits a refresh
|
||||||
// after the failed try, as it does while it runs.
|
// after the failed try, as it does while it runs.
|
||||||
restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}}
|
restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}}
|
||||||
again := reputation.New(params(dropURL))
|
again := reputation.New(params(dropURL))
|
||||||
@@ -353,7 +353,7 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) {
|
|||||||
waiting(queue))
|
waiting(queue))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restarted an hour later with what lists.json keeps, it fetches
|
// Restarted an hour later with what reputation.json keeps, it fetches
|
||||||
// tor.txt, never tried, at once, and drop.txt a refresh after its
|
// tor.txt, never tried, at once, and drop.txt a refresh after its
|
||||||
// cut-off try.
|
// cut-off try.
|
||||||
time.Sleep(time.Hour)
|
time.Sleep(time.Hour)
|
||||||
|
|||||||
@@ -44,17 +44,17 @@ const (
|
|||||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
||||||
|
|
||||||
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
||||||
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
||||||
|
|
||||||
// ActionClientDenied is a request refused because its client is in
|
// ActionClientDenied is a request refused because its client is in
|
||||||
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
|
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||||
ActionClientDenied = "client_denied"
|
ActionClientDenied = "client_denied"
|
||||||
|
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
// ActionCountryDenied is a request refused because its client's country
|
||||||
// is not in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionOwnEndpoint is a request waf 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/.
|
// endpoints, or a question about a path under /_sww/.
|
||||||
@@ -126,7 +126,7 @@ type Line struct {
|
|||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
// ContentLength is the length of its body the request announced.
|
// ContentLength is the length of its body the request announced.
|
||||||
ContentLength int64 `json:"content_length,omitempty"`
|
ContentLength int64 `json:"content_length,omitempty"`
|
||||||
// RequestHeaders are the headers SWW_WAF_LOG_REQUEST_HEADERS names that
|
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
||||||
// the request carried, by name in lower case.
|
// the request carried, by name in lower case.
|
||||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||||
@@ -189,7 +189,7 @@ type Line struct {
|
|||||||
Reputation []string `json:"reputation,omitempty"`
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
// Tags are the request's tags, sorted.
|
// Tags are the request's tags, sorted.
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
// RouteWatchTagsMissing, while SWW_WAF_ROUTEWATCH_URL is set, says why the
|
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
|
||||||
// request got no tags from routewatch: no_answer_yet, request_failed or
|
// request got no tags from routewatch: no_answer_yet, request_failed or
|
||||||
// no_route.
|
// no_route.
|
||||||
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
||||||
@@ -233,7 +233,7 @@ type RProxyLine struct {
|
|||||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||||
// Disposition is the valid disposition waf answered or, while
|
// Disposition is the valid disposition waf answered or, while
|
||||||
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
|
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||||
// when neither gave one.
|
// when neither gave one.
|
||||||
Disposition string `json:"disposition,omitempty"`
|
Disposition string `json:"disposition,omitempty"`
|
||||||
DefaultDisposition bool `json:"default_disposition,omitempty"`
|
DefaultDisposition bool `json:"default_disposition,omitempty"`
|
||||||
@@ -291,8 +291,8 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_WAF_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(
|
func NewProcessLogger(
|
||||||
w io.Writer,
|
w io.Writer,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Package routewatch asks a routewatch instance about client addresses, in the
|
// Package routewatch asks a routewatch instance about client addresses, in the
|
||||||
// background, and keeps its answers. A routewatch instance knows, for an
|
// background, and keeps its answers in memory. A routewatch instance knows, for
|
||||||
// address, the netblock it is announced in and the AS that originates it. The
|
// an address, the netblock it is announced in and the AS that originates it.
|
||||||
// answers are kept in memory, for at most 100,000 clients and for an hour each,
|
|
||||||
// and are written to routewatch.json and read from it by the state package.
|
|
||||||
package routewatch
|
package routewatch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,7 +13,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -68,20 +65,9 @@ type Route struct {
|
|||||||
ASN uint32
|
ASN uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answer is what routewatch answered about a client, as routewatch.json holds
|
|
||||||
// it: the netblock the client's address is announced in, none for an address
|
|
||||||
// without a live route, the AS that originates it, and when routewatch
|
|
||||||
// answered.
|
|
||||||
type Answer struct {
|
|
||||||
Client netip.Prefix `json:"client"`
|
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
|
||||||
ASN uint32 `json:"asn"`
|
|
||||||
Answered time.Time `json:"answered"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is where routewatch is (SWW_WAF_ROUTEWATCH_URL).
|
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
|
||||||
URL string
|
URL string
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -98,16 +84,23 @@ type Client struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// answers are by client. Each is added as it comes and never moved up,
|
// answers are by client. Each is added as it comes and never moved up,
|
||||||
// so that the one that came longest ago is the first dropped.
|
// so that the one that came longest ago is the first dropped.
|
||||||
answers *simplelru.LRU[netip.Prefix, Answer]
|
answers *simplelru.LRU[netip.Prefix, answer]
|
||||||
// asking are the clients whose query is under way.
|
// asking are the clients whose query is under way.
|
||||||
asking map[netip.Prefix]bool
|
asking map[netip.Prefix]bool
|
||||||
// retryAt is when routewatch may be asked again after a query failed.
|
// retryAt is when routewatch may be asked again after a query failed.
|
||||||
retryAt time.Time
|
retryAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answer is one kept answer: the route, or why there is none, and when it came.
|
||||||
|
type answer struct {
|
||||||
|
route Route
|
||||||
|
missing Missing
|
||||||
|
came time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// New returns a Client with no answer yet.
|
// New returns a Client with no answer yet.
|
||||||
func New(params Params) *Client {
|
func New(params Params) *Client {
|
||||||
answers, err := simplelru.NewLRU[netip.Prefix, Answer](maxAnswers, nil)
|
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err) // NewLRU fails only for a size below one
|
panic(err) // NewLRU fails only for a size below one
|
||||||
}
|
}
|
||||||
@@ -135,12 +128,8 @@ func (c *Client) Route(
|
|||||||
now := c.params.Now()
|
now := c.params.Now()
|
||||||
|
|
||||||
kept, found := c.answers.Peek(client)
|
kept, found := c.answers.Peek(client)
|
||||||
if found && now.Sub(kept.Answered) < answerKept {
|
if found && now.Sub(kept.came) < answerKept {
|
||||||
if !kept.Netblock.IsValid() {
|
return kept.route, kept.missing
|
||||||
return Route{}, NoRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
return Route{Netblock: kept.Netblock, ASN: kept.ASN}, ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if now.Before(c.retryAt) {
|
if now.Before(c.retryAt) {
|
||||||
@@ -156,58 +145,12 @@ func (c *Client) Route(
|
|||||||
return Route{}, NoAnswerYet
|
return Route{}, NoAnswerYet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns every answer still in use, sorted by client, as
|
|
||||||
// routewatch.json lists them.
|
|
||||||
func (c *Client) Snapshot() []Answer {
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
now := c.params.Now()
|
|
||||||
answers := make([]Answer, 0, c.answers.Len())
|
|
||||||
|
|
||||||
for _, kept := range c.answers.Values() {
|
|
||||||
if now.Sub(kept.Answered) < answerKept {
|
|
||||||
answers = append(answers, kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
slices.SortFunc(answers, func(a, b Answer) int {
|
|
||||||
return a.Client.Compare(b.Client)
|
|
||||||
})
|
|
||||||
|
|
||||||
return answers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load keeps answers, read from routewatch.json, in place of those it keeps,
|
|
||||||
// but for those that came answerKept ago or more and, past maxAnswers, those
|
|
||||||
// that came longest ago.
|
|
||||||
func (c *Client) Load(answers []Answer) {
|
|
||||||
answers = slices.Clone(answers)
|
|
||||||
slices.SortStableFunc(answers, func(a, b Answer) int {
|
|
||||||
return a.Answered.Compare(b.Answered)
|
|
||||||
})
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.answers.Purge()
|
|
||||||
|
|
||||||
now := c.params.Now()
|
|
||||||
|
|
||||||
for _, kept := range answers {
|
|
||||||
if now.Sub(kept.Answered) < answerKept {
|
|
||||||
c.answers.Add(kept.Client, kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
||||||
// query as no longer under way. A query that fails gives no answer, and
|
// query as no longer under way. A query that fails gives no answer, and
|
||||||
// routewatch is not asked again for failureKept. Only the first failure of
|
// routewatch is not asked again for failureKept. Only the first failure of
|
||||||
// those under way is logged.
|
// those under way is logged.
|
||||||
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
||||||
route, err := c.query(ctx, addr)
|
route, missing, err := c.query(ctx, addr)
|
||||||
now := c.params.Now()
|
now := c.params.Now()
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -217,9 +160,7 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr)
|
|||||||
firstFailure := err != nil && !now.Before(c.retryAt)
|
firstFailure := err != nil && !now.Before(c.retryAt)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.answers.Add(client, Answer{
|
c.answers.Add(client, answer{route: route, missing: missing, came: now})
|
||||||
Client: client, Netblock: route.Netblock, ASN: route.ASN, Answered: now,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
c.retryAt = now.Add(failureKept)
|
c.retryAt = now.Add(failureKept)
|
||||||
}
|
}
|
||||||
@@ -233,19 +174,19 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr)
|
|||||||
|
|
||||||
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
||||||
// that cannot be read, and none within queryTimeout, fail. A result with no
|
// that cannot be read, and none within queryTimeout, fail. A result with no
|
||||||
// netblock is an address without a live route, and gives a Route without one.
|
// netblock is an address without a live route.
|
||||||
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) {
|
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Route{}, fmt.Errorf("make the URL: %w", err)
|
return Route{}, "", fmt.Errorf("make the URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Route{}, fmt.Errorf("make the request: %w", err)
|
return Route{}, "", fmt.Errorf("make the request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
@@ -254,7 +195,7 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Do's error names the URL, which holds the client's address, which
|
// Do's error names the URL, which holds the client's address, which
|
||||||
// is not to be logged: only what went wrong is kept.
|
// is not to be logged: only what went wrong is kept.
|
||||||
return Route{}, fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -262,14 +203,14 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
if res.StatusCode != http.StatusOK {
|
||||||
return Route{}, fmt.Errorf("%w %s", errStatus, res.Status)
|
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// readAnswer reads routewatch's answer about one address.
|
// readAnswer reads routewatch's answer about one address.
|
||||||
func readAnswer(body io.Reader) (Route, error) {
|
func readAnswer(body io.Reader) (Route, Missing, error) {
|
||||||
var decoded struct {
|
var decoded struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
@@ -281,22 +222,22 @@ func readAnswer(body io.Reader) (Route, error) {
|
|||||||
|
|
||||||
err := json.NewDecoder(body).Decode(&decoded)
|
err := json.NewDecoder(body).Decode(&decoded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Route{}, fmt.Errorf("read the answer: %w", err)
|
return Route{}, "", fmt.Errorf("read the answer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(decoded.Data.Results) == 0 {
|
if len(decoded.Data.Results) == 0 {
|
||||||
return Route{}, errNoResult
|
return Route{}, "", errNoResult
|
||||||
}
|
}
|
||||||
|
|
||||||
result := decoded.Data.Results[0]
|
result := decoded.Data.Results[0]
|
||||||
if result.Netblock == "" {
|
if result.Netblock == "" {
|
||||||
return Route{}, nil
|
return Route{}, NoRoute, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
netblock, err := netip.ParsePrefix(result.Netblock)
|
netblock, err := netip.ParsePrefix(result.Netblock)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Route{}, fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, nil
|
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||||
@@ -115,28 +113,6 @@ func answering(body string) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// silentURL is where a routewatch that never answers is: a query to it waits,
|
|
||||||
// without the network, until it is abandoned. TestMain registers it with Go's
|
|
||||||
// default transport, through which the Client asks.
|
|
||||||
const silentURL = "silent://routewatch/"
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
|
||||||
transport.RegisterProtocol("silent", silentRoutewatch{})
|
|
||||||
|
|
||||||
m.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// silentRoutewatch is the routewatch at silentURL.
|
|
||||||
type silentRoutewatch struct{}
|
|
||||||
|
|
||||||
// RoundTrip waits until req is abandoned.
|
|
||||||
func (silentRoutewatch) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
<-req.Context().Done()
|
|
||||||
|
|
||||||
return nil, req.Context().Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
||||||
addr := netip.MustParseAddr(clientAddr)
|
addr := netip.MustParseAddr(clientAddr)
|
||||||
client := netip.PrefixFrom(addr, addr.BitLen())
|
client := netip.PrefixFrom(addr, addr.BitLen())
|
||||||
@@ -311,29 +287,20 @@ func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
|
|||||||
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// In a synctest bubble, time passes only while every goroutine in it waits,
|
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
// so Route's wait is measured exactly, however busy the host is. routewatch
|
<-r.Context().Done()
|
||||||
// is the one at silentURL: one reached over the network would keep the
|
|
||||||
// bubble's clock from moving.
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
// Without newFixture's stand-in, which is reached over the network.
|
|
||||||
f := &fixture{t: t, log: &syncBuffer{}}
|
|
||||||
f.client = routewatch.New(routewatch.Params{
|
|
||||||
URL: silentURL, Now: time.Now,
|
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
|
||||||
})
|
|
||||||
|
|
||||||
began := time.Now()
|
|
||||||
_, missing := f.route()
|
|
||||||
|
|
||||||
if waited := time.Since(began); missing != routewatch.NoAnswerYet || waited != 0 {
|
|
||||||
t.Errorf("Route gave %q after %s while routewatch hangs, want %q at once",
|
|
||||||
missing, waited, routewatch.NoAnswerYet)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.waitFor(routewatch.RequestFailed)
|
|
||||||
f.wantFailureLoggedWithoutTheClient()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
f.route()
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("Route took %s while routewatch hangs", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
f.wantFailureLoggedWithoutTheClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
||||||
@@ -369,96 +336,3 @@ func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
|||||||
t.Errorf("%d queries after the answer expired, want 2", got)
|
t.Errorf("%d queries after the answer expired, want 2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotHoldsTheAnswerUntilItExpires(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
f.waitFor("")
|
|
||||||
|
|
||||||
want := []routewatch.Answer{{
|
|
||||||
Client: netip.MustParsePrefix(clientAddr + "/32"),
|
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496,
|
|
||||||
Answered: f.clk.Now(),
|
|
||||||
}}
|
|
||||||
if got := f.client.Snapshot(); !slices.Equal(got, want) {
|
|
||||||
t.Errorf("snapshot %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.clk.advance(time.Hour)
|
|
||||||
|
|
||||||
if got := f.client.Snapshot(); len(got) != 0 {
|
|
||||||
t.Errorf("snapshot %+v an hour after the answer, want none", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
|
||||||
now := f.clk.Now()
|
|
||||||
kept := routewatch.Answer{
|
|
||||||
Client: netip.MustParsePrefix(clientAddr + "/32"),
|
|
||||||
Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497,
|
|
||||||
Answered: now.Add(-time.Hour + time.Second),
|
|
||||||
}
|
|
||||||
stale := routewatch.Answer{
|
|
||||||
Client: netip.MustParsePrefix("192.0.2.2/32"),
|
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496,
|
|
||||||
Answered: now.Add(-time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
f.client.Load([]routewatch.Answer{kept, stale})
|
|
||||||
|
|
||||||
if got := f.client.Snapshot(); !slices.Equal(got, []routewatch.Answer{kept}) {
|
|
||||||
t.Errorf("kept %+v, want only the answer that came less than an hour ago", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
route, missing := f.route()
|
|
||||||
|
|
||||||
want := routewatch.Route{Netblock: kept.Netblock, ASN: kept.ASN}
|
|
||||||
if route != want || missing != "" {
|
|
||||||
t.Errorf("route %+v (%q), want %+v", route, missing, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := f.queries.Load(); got != 0 {
|
|
||||||
t.Errorf("%d queries for a client with an answer loaded, want none", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadDropsTheAnswerThatCameLongestAgoFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const maxAnswers = 100000
|
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
|
||||||
now := f.clk.Now()
|
|
||||||
|
|
||||||
// routewatch.json lists the answers by client. Here each came a
|
|
||||||
// millisecond before the one listed before it, so the last listed is
|
|
||||||
// the one that came longest ago, and the one dropped.
|
|
||||||
answers := make([]routewatch.Answer, maxAnswers+1)
|
|
||||||
addr := netip.MustParseAddr("10.0.0.0")
|
|
||||||
|
|
||||||
for i := range answers {
|
|
||||||
answers[i] = routewatch.Answer{
|
|
||||||
Client: netip.PrefixFrom(addr, addr.BitLen()),
|
|
||||||
Answered: now.Add(-time.Duration(i) * time.Millisecond),
|
|
||||||
}
|
|
||||||
addr = addr.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
f.client.Load(answers)
|
|
||||||
|
|
||||||
got := f.client.Snapshot()
|
|
||||||
if len(got) != maxAnswers {
|
|
||||||
t.Fatalf("%d answers kept, want %d", len(got), maxAnswers)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] {
|
|
||||||
t.Errorf("answers kept from %s to %s, want from %s to %s", got[0].Client,
|
|
||||||
got[maxAnswers-1].Client, answers[0].Client, answers[maxAnswers-1].Client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,16 +11,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// errResponseTooLarge ends an app's response body that is longer than
|
// errResponseTooLarge ends an app's response body that is longer than
|
||||||
// SWW_RPROXY_MAX_RESPONSE_SIZE.
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
var errResponseTooLarge = errors.New(
|
var errResponseTooLarge = errors.New(
|
||||||
"the response body is over SWW_RPROXY_MAX_RESPONSE_SIZE")
|
"the response body is over SWRP_MAX_RESPONSE_SIZE")
|
||||||
|
|
||||||
// requestBody is the visitor's request body. rproxy first reads up to the first
|
// requestBody is the visitor's request body. rproxy first reads up to the first
|
||||||
// 64 KiB of it into held, and then, on its way to the app, the transport reads
|
// 64 KiB of it into held, and then, on its way to the app, the transport reads
|
||||||
// it on a goroutine of its own.
|
// it on a goroutine of its own.
|
||||||
type requestBody struct {
|
type requestBody struct {
|
||||||
// body is the visitor's body, ending in an *http.MaxBytesError past
|
// body is the visitor's body, ending in an *http.MaxBytesError past
|
||||||
// SWW_RPROXY_MAX_REQUEST_SIZE.
|
// SWRP_MAX_REQUEST_SIZE.
|
||||||
body io.ReadCloser
|
body io.ReadCloser
|
||||||
rq *request
|
rq *request
|
||||||
// held is the part read before the request goes to the app, which Read gives
|
// held is the part read before the request goes to the app, which Read gives
|
||||||
@@ -84,7 +84,7 @@ func (b *requestBody) Close() error {
|
|||||||
// responseBody is the app's response body on its way to the visitor.
|
// responseBody is the app's response body on its way to the visitor.
|
||||||
type responseBody struct {
|
type responseBody struct {
|
||||||
// body is the app's body, ending in an *http.MaxBytesError past
|
// body is the app's body, ending in an *http.MaxBytesError past
|
||||||
// SWW_RPROXY_MAX_RESPONSE_SIZE.
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
body io.ReadCloser
|
body io.ReadCloser
|
||||||
rq *request
|
rq *request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
@@ -35,9 +35,13 @@ func announcedLength(size int) string {
|
|||||||
func TestServerHasTheFixedLimits(t *testing.T) {
|
func TestServerHasTheFixedLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(configtest.Viper(map[string]string{
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
"SWW_RPROXY_WAF_BACKEND": nowhere,
|
if name == "SWRP_WAF_BACKEND" {
|
||||||
}))
|
return nowhere, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings: %v", err)
|
t.Fatalf("settings: %v", err)
|
||||||
}
|
}
|
||||||
@@ -379,20 +383,6 @@ type deadlineRecorder struct {
|
|||||||
// stalled, if set, sends the part of the request body that never arrives. A
|
// stalled, if set, sends the part of the request body that never arrives. A
|
||||||
// read deadline that has passed ends a read of it, as on a connection.
|
// read deadline that has passed ends a read of it, as on a connection.
|
||||||
stalled *io.PipeWriter
|
stalled *io.PipeWriter
|
||||||
// flushError, if set, is what every flush returns, and nothing is flushed.
|
|
||||||
flushError error
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlushError, which http.ResponseController calls to flush, returns flushError
|
|
||||||
// if it is set, and otherwise flushes.
|
|
||||||
func (w *deadlineRecorder) FlushError() error {
|
|
||||||
if w.flushError != nil {
|
|
||||||
return w.flushError
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Flush()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetReadDeadline notes deadline.
|
// SetReadDeadline notes deadline.
|
||||||
@@ -458,45 +448,6 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
standIn := startWAF(t, allow)
|
|
||||||
app := startApp(t, answerBeforeTheBody)
|
|
||||||
_, _, server := startServer(t, standIn.URL, app.URL, nil)
|
|
||||||
|
|
||||||
// The visitor sends the first part and a little more, and then nothing.
|
|
||||||
stalled, stop := io.Pipe()
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = stop.Close() })
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
|
|
||||||
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
|
|
||||||
req.ContentLength = 2 * questionBodyMaxBytes
|
|
||||||
req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes))
|
|
||||||
|
|
||||||
// Every flush fails, as once the time to send the answer has run out. The
|
|
||||||
// default time limits are far longer than the test, so rproxy records no
|
|
||||||
// refusal.
|
|
||||||
w := &deadlineRecorder{
|
|
||||||
ResponseRecorder: httptest.NewRecorder(),
|
|
||||||
stalled: stop,
|
|
||||||
flushError: os.ErrDeadlineExceeded,
|
|
||||||
}
|
|
||||||
|
|
||||||
before := time.Now()
|
|
||||||
|
|
||||||
server.Handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("status %d, want the app's 200", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) {
|
|
||||||
t.Errorf("the reading of the body ends at %s, want now", w.readDeadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -698,7 +649,7 @@ func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// visitAsTheTimeRunsOut sends requests to an app that answers about as
|
// visitAsTheTimeRunsOut sends requests to an app that answers about as
|
||||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
|
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
|
||||||
// app's 200 or a 504.
|
// app's 200 or a 504.
|
||||||
func visitAsTheTimeRunsOut(t *testing.T) {
|
func visitAsTheTimeRunsOut(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -967,45 +918,49 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// rproxy passes on a part sent at once well within this limit, even on a busy
|
const limit = 300 * time.Millisecond
|
||||||
// host.
|
|
||||||
const limit = 2 * time.Second
|
|
||||||
|
|
||||||
// The app sends the parts in atOnce with its status and headers. It sends the
|
// With two parts, rproxy fails to send the first and then waits for the
|
||||||
// parts in late only once rproxy has ended the request to it, which rproxy
|
// second, which the app sends only once rproxy has ended the request to it.
|
||||||
// does when it cuts off the answer, so that those never reach the visitor.
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
atOnce, late []string
|
parts []string
|
||||||
}{
|
}{
|
||||||
{"with the body in one part", nil, []string{greeting}},
|
{"with the body in one part", []string{greeting}},
|
||||||
{"with the body in two parts", []string{greeting[:5]}, []string{greeting[5:]}},
|
{"with the body in two parts", []string{greeting[:5], greeting[5:]}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
headersRead := make(chan struct{})
|
||||||
|
// letAppSendBody also runs when the test ends, as the app's server waits
|
||||||
|
// for the app.
|
||||||
|
letAppSendBody := sync.OnceFunc(func() { close(headersRead) })
|
||||||
standIn := startWAF(t, allow)
|
standIn := startWAF(t, allow)
|
||||||
|
// The app sends its body once the visitor has the status and headers and
|
||||||
|
// the response time limit has run out since, so that rproxy cannot send
|
||||||
|
// it. After each part, it reads the request's body, which ends when rproxy
|
||||||
|
// ends the request to it.
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
rc := http.NewResponseController(w)
|
rc := http.NewResponseController(w)
|
||||||
_ = rc.EnableFullDuplex()
|
_ = rc.EnableFullDuplex()
|
||||||
|
|
||||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
|
w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
|
||||||
|
|
||||||
for _, part := range tc.atOnce {
|
|
||||||
_, _ = io.WriteString(w, part)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = rc.Flush()
|
_ = rc.Flush()
|
||||||
|
|
||||||
// The request's body ends when rproxy ends the request to the app.
|
<-headersRead
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
time.Sleep(limit)
|
||||||
|
|
||||||
for _, part := range tc.late {
|
for _, part := range tc.parts {
|
||||||
_, _ = io.WriteString(w, part)
|
_, _ = io.WriteString(w, part)
|
||||||
_ = rc.Flush()
|
_ = rc.Flush()
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Cleanup(letAppSendBody)
|
||||||
|
|
||||||
// The request time limit is far longer than dial's deadline.
|
// The request time limit is far longer than dial's deadline.
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "120000",
|
maxRequestTime: "120000",
|
||||||
@@ -1020,14 +975,15 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
|
|||||||
t.Fatalf("read the answer: %v", err)
|
t.Fatalf("read the answer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
letAppSendBody()
|
||||||
|
|
||||||
// If the connection stays open, dial's deadline ends this read with a
|
// If the connection stays open, dial's deadline ends this read with a
|
||||||
// timeout.
|
// timeout.
|
||||||
body, err := io.ReadAll(res.Body)
|
body, err := io.ReadAll(res.Body)
|
||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
|
|
||||||
want := strings.Join(tc.atOnce, "")
|
if len(body) != 0 || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
if string(body) != want || !errors.Is(err, io.ErrUnexpectedEOF) {
|
t.Errorf("read %q (%v), want the connection closed", body, err)
|
||||||
t.Errorf("read %q (%v), want %q and the connection closed", body, err, want)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-49
@@ -34,14 +34,14 @@ const plainText = "text/plain; charset=utf-8"
|
|||||||
|
|
||||||
// minSendTime is the least time to send an answer rproxy gives itself, or the
|
// minSendTime is the least time to send an answer rproxy gives itself, or the
|
||||||
// status and headers of the app's answer. A shorter time can run out before the
|
// status and headers of the app's answer. A shorter time can run out before the
|
||||||
// first byte is written, on a busy host or as SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out,
|
// first byte is written, on a busy host or as SWRP_MAX_RESPONSE_TIME_MS runs out,
|
||||||
// and the visitor then gets a closed connection and no answer.
|
// and the visitor then gets a closed connection and no answer.
|
||||||
const minSendTime = 10 * time.Second
|
const minSendTime = 10 * time.Second
|
||||||
|
|
||||||
// errResponseTooLate refuses an app's answer that arrives once
|
// errResponseTooLate refuses an app's answer that arrives once
|
||||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
// SWRP_MAX_RESPONSE_TIME_MS has run out.
|
||||||
var errResponseTooLate = errors.New(
|
var errResponseTooLate = errors.New(
|
||||||
"the answer arrived after SWW_RPROXY_MAX_RESPONSE_TIME_MS ran out")
|
"the answer arrived after SWRP_MAX_RESPONSE_TIME_MS ran out")
|
||||||
|
|
||||||
// request is one request on its way through rproxy, from the moment its headers
|
// request is one request on its way through rproxy, from the moment its headers
|
||||||
// have been read to its log line and report.
|
// have been read to its log line and report.
|
||||||
@@ -61,10 +61,10 @@ type request struct {
|
|||||||
start time.Time
|
start time.Time
|
||||||
|
|
||||||
// disposition is what is done with the request: waf's valid disposition,
|
// disposition is what is done with the request: waf's valid disposition,
|
||||||
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||||
disposition string
|
disposition string
|
||||||
// answered is true when waf gave disposition, which makes a report due,
|
// answered is true when waf gave disposition, which makes a report due,
|
||||||
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
|
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||||
answered bool
|
answered bool
|
||||||
byDefault bool
|
byDefault bool
|
||||||
// asn and country are waf'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
|
||||||
@@ -127,10 +127,10 @@ func (rq *request) answerHealth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
|
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
|
||||||
// within SWW_RPROXY_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
|
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
|
||||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||||
// visitor that went away or stopped its sending side, the connection is closed
|
// visitor that went away or stopped its sending side, the connection is closed
|
||||||
// without an answer. A body over SWW_RPROXY_MAX_REQUEST_SIZE goes on with the part
|
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||||
// read, so that waf is asked first; passToApp then refuses it.
|
// read, so that waf is asked first; passToApp then refuses it.
|
||||||
func (rq *request) readFirstPartOfBody() bool {
|
func (rq *request) readFirstPartOfBody() bool {
|
||||||
if rq.body == nil {
|
if rq.body == nil {
|
||||||
@@ -198,7 +198,7 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||||
// sent to SWW_RPROXY_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||||
// waf's.
|
// waf's.
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
@@ -247,7 +247,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
|
|
||||||
requestEnd := rq.endRequest()
|
requestEnd := rq.endRequest()
|
||||||
|
|
||||||
// An answer that arrives once SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out is not
|
// An answer that arrives once SWRP_MAX_RESPONSE_TIME_MS has run out is not
|
||||||
// passed on: the visitor is answered 504.
|
// passed on: the visitor is answered 504.
|
||||||
limit := rq.h.config.MaxResponseTime
|
limit := rq.h.config.MaxResponseTime
|
||||||
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
|
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
|
||||||
@@ -272,10 +272,6 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
|
|
||||||
rq.closeAfterAnswer()
|
rq.closeAfterAnswer()
|
||||||
|
|
||||||
// An answer that arrives before the app has been sent the whole request ends
|
|
||||||
// the request, and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
|
|
||||||
rq.startResponseTimer(requestEnd)
|
|
||||||
|
|
||||||
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
||||||
rq.out.afterStatus = rq.setResponseDeadline
|
rq.out.afterStatus = rq.setResponseDeadline
|
||||||
|
|
||||||
@@ -361,7 +357,7 @@ func (rq *request) closeAfterAnswer() {
|
|||||||
|
|
||||||
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
|
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
|
||||||
// reading of a body that has not arrived whole ends at deadline, and the answer
|
// reading of a body that has not arrived whole ends at deadline, and the answer
|
||||||
// has SWW_RPROXY_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
||||||
// used that time up first, and at least minSendTime.
|
// used that time up first, and at least minSendTime.
|
||||||
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
||||||
rq.stopReadingBody(deadline)
|
rq.stopReadingBody(deadline)
|
||||||
@@ -390,18 +386,14 @@ func (rq *request) finish(ctx context.Context) {
|
|||||||
|
|
||||||
// After a cut-off answer, the reading of a body that has not arrived whole
|
// After a cut-off answer, the reading of a body that has not arrived whole
|
||||||
// ends now. Go's server would otherwise keep the connection open while it
|
// ends now. Go's server would otherwise keep the connection open while it
|
||||||
// reads the rest. modifyResponse sets afterStatus only when it passes the
|
// reads the rest. A flush fails if the time to send the answer ran out, and
|
||||||
// app's answer on. Any refusal after that has cut the answer off, since a
|
// every flush after a failed one fails too. ReverseProxy ignores the failure,
|
||||||
// refusal ends the request to the app. Any answer is cut off when the time to
|
// but panics if it was still waiting for more of the app's answer; finish runs
|
||||||
// send it runs out: a flush then fails, and every flush after a failed one
|
// either way. Before a status, a flush would send a 200; after a 101,
|
||||||
// fails too. ReverseProxy ignores the failure, but panics if it was still
|
// ReverseProxy has taken over the connection.
|
||||||
// waiting for more of the app's answer; finish runs either way. Before a
|
|
||||||
// status, a flush would send a 200; after a 101, ReverseProxy has taken over
|
|
||||||
// the connection.
|
|
||||||
switch {
|
switch {
|
||||||
case rq.out.afterStatus != nil && rq.refused.Load() != 0,
|
case rq.out.status != 0 && rq.out.status != http.StatusSwitchingProtocols &&
|
||||||
rq.out.status != 0 && rq.out.status != http.StatusSwitchingProtocols &&
|
errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded):
|
||||||
errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded):
|
|
||||||
rq.stopPassingOnBody(time.Now())
|
rq.stopPassingOnBody(time.Now())
|
||||||
case rq.refused.Load() == 0:
|
case rq.refused.Load() == 0:
|
||||||
rq.stopPassingOnBody(rq.requestDeadline())
|
rq.stopPassingOnBody(rq.requestDeadline())
|
||||||
@@ -470,7 +462,7 @@ func (rq *request) bytesEachWay() (int64, int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requestDeadline is when the visitor must have sent its whole request, or zero
|
// requestDeadline is when the visitor must have sent its whole request, or zero
|
||||||
// when SWW_RPROXY_MAX_REQUEST_TIME_MS is off.
|
// when SWRP_MAX_REQUEST_TIME_MS is off.
|
||||||
func (rq *request) requestDeadline() time.Time {
|
func (rq *request) requestDeadline() time.Time {
|
||||||
limit := rq.h.config.MaxRequestTime
|
limit := rq.h.config.MaxRequestTime
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
@@ -511,7 +503,7 @@ func (rq *request) stopPassingOnBody(deadline time.Time) {
|
|||||||
rq.stopReadingBody(deadline)
|
rq.stopReadingBody(deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRequestTimer starts SWW_RPROXY_MAX_REQUEST_TIME_MS, which runs from the
|
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
|
||||||
// request's headers being read until the app has been sent the whole request.
|
// request's headers being read until the app has been sent the whole request.
|
||||||
func (rq *request) startRequestTimer() {
|
func (rq *request) startRequestTimer() {
|
||||||
deadline := rq.requestDeadline()
|
deadline := rq.requestDeadline()
|
||||||
@@ -525,7 +517,7 @@ func (rq *request) startRequestTimer() {
|
|||||||
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
|
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestTimedOut is called when SWW_RPROXY_MAX_REQUEST_TIME_MS runs out while the
|
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
|
||||||
// request is still on its way to the app. The answer names the side rproxy was
|
// request is still on its way to the app. The answer names the side rproxy was
|
||||||
// waiting on at that moment: 408 when it was waiting for the visitor to send more
|
// waiting on at that moment: 408 when it was waiting for the visitor to send more
|
||||||
// of its body, 504 when it was waiting for the app to be reached or to take what
|
// of its body, 504 when it was waiting for the app to be reached or to take what
|
||||||
@@ -552,7 +544,7 @@ func (rq *request) requestTimedOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wroteRequest is called once the app has been sent the whole request:
|
// wroteRequest is called once the app has been sent the whole request:
|
||||||
// SWW_RPROXY_MAX_REQUEST_TIME_MS ends and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
|
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
|
||||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||||
if info.Err != nil {
|
if info.Err != nil {
|
||||||
return // the transport gives up, or tries again
|
return // the transport gives up, or tries again
|
||||||
@@ -560,33 +552,23 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
|||||||
|
|
||||||
requestEnd := rq.endRequest()
|
requestEnd := rq.endRequest()
|
||||||
|
|
||||||
rq.mu.Lock()
|
|
||||||
stopTimer(rq.requestTimer)
|
|
||||||
rq.mu.Unlock()
|
|
||||||
|
|
||||||
rq.startResponseTimer(requestEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// startResponseTimer starts SWW_RPROXY_MAX_RESPONSE_TIME_MS, which runs from
|
|
||||||
// requestEnd, unless it has started already.
|
|
||||||
func (rq *request) startResponseTimer(requestEnd time.Time) {
|
|
||||||
limit := rq.h.config.MaxResponseTime
|
|
||||||
if limit == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.mu.Lock()
|
rq.mu.Lock()
|
||||||
defer rq.mu.Unlock()
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
if rq.timersStopped || rq.responseTimer != nil {
|
if rq.timersStopped {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
|
stopTimer(rq.requestTimer)
|
||||||
rq.responseTimedOut)
|
|
||||||
|
limit := rq.h.config.MaxResponseTime
|
||||||
|
if limit > 0 {
|
||||||
|
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
|
||||||
|
rq.responseTimedOut)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseTimedOut is called when SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out before the
|
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the
|
||||||
// app has sent its whole answer.
|
// app has sent its whole answer.
|
||||||
func (rq *request) responseTimedOut() {
|
func (rq *request) responseTimedOut() {
|
||||||
rq.mu.Lock()
|
rq.mu.Lock()
|
||||||
@@ -604,7 +586,7 @@ func (rq *request) responseReceived() {
|
|||||||
|
|
||||||
// setResponseDeadline sends the status and headers of the app's answer, with
|
// setResponseDeadline sends the status and headers of the app's answer, with
|
||||||
// at least minSendTime, before ReverseProxy reads the answer's body, and then
|
// at least minSendTime, before ReverseProxy reads the answer's body, and then
|
||||||
// the rest has until SWW_RPROXY_MAX_RESPONSE_TIME_MS after the end of the request.
|
// the rest has until SWRP_MAX_RESPONSE_TIME_MS after the end of the request.
|
||||||
func (rq *request) setResponseDeadline() {
|
func (rq *request) setResponseDeadline() {
|
||||||
limit := rq.h.config.MaxResponseTime
|
limit := rq.h.config.MaxResponseTime
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Package rproxy is the reverse proxy in front of one app that asks waf
|
// 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
|
// 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
|
// app's answer back, within the size and time limits. Any other answer from
|
||||||
// waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
|
// 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
|
// decides only when waf cannot be connected to, does not answer at all, or
|
||||||
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
|
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
// method it does not pass on, or one it cannot put to waf 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 writes nothing to disk. "rproxy" in
|
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||||
// SPEC.md says what it does.
|
// SPEC.md says what it does.
|
||||||
package rproxy
|
package rproxy
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ type Server struct {
|
|||||||
|
|
||||||
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
||||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||||
// connection idle for idleTimeout, and applies SWW_RPROXY_MAX_REQUEST_TIME_MS while
|
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||||
// the headers arrive. The handler applies the time and size limits from then
|
// the headers arrive. The handler applies the time and size limits from then
|
||||||
// on.
|
// on.
|
||||||
func New(params Params) *Server {
|
func New(params Params) *Server {
|
||||||
@@ -105,7 +105,7 @@ func New(params Params) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WaitForReports returns once the reports being sent have gone or failed,
|
// WaitForReports returns once the reports being sent have gone or failed,
|
||||||
// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
||||||
// a WebSocket that closes while rproxy stops, for one.
|
// a WebSocket that closes while rproxy stops, for one.
|
||||||
func (s *Server) WaitForReports() {
|
func (s *Server) WaitForReports() {
|
||||||
s.h.reportsMu.Lock()
|
s.h.reportsMu.Lock()
|
||||||
@@ -214,8 +214,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||||
// it from the app. waf 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 SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
|
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||||
// ctx is the request's own context.
|
// request's own context.
|
||||||
func (rq *request) passToApp(ctx context.Context) {
|
func (rq *request) passToApp(ctx context.Context) {
|
||||||
maxBytes := rq.h.config.MaxRequestSize
|
maxBytes := rq.h.config.MaxRequestSize
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
@@ -44,13 +43,13 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
|
timeoutSetting = "SWRP_TIMEOUT_MS"
|
||||||
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
defaultDisposition = "SWRP_DEFAULT_DISPOSITION"
|
||||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
maxRequestSize = "SWRP_MAX_REQUEST_SIZE"
|
||||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers the tests set and look at.
|
// The headers the tests set and look at.
|
||||||
@@ -355,11 +354,15 @@ func startServerLogging(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings))
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
|
value, ok := settings[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings %v: %v", settings, err)
|
t.Fatalf("settings %v: %v", settings, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// waf cannot be connected to, does not answer at all, or does not answer
|
// waf cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
|
// within SWRP_TIMEOUT_MS. A request that keeps waf busy past
|
||||||
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
|
// SWRP_TIMEOUT_MS, like any request while waf is overloaded, gets
|
||||||
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||||
// whenever waf 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.
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||||
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
||||||
@@ -84,7 +84,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask sends waf 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 SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||||
// away.
|
// away.
|
||||||
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||||
@@ -228,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postReport posts report to waf, and waits for its answer for up to
|
// postReport posts report to waf, and waits for its answer for up to
|
||||||
// SWW_RPROXY_TIMEOUT_MS.
|
// SWRP_TIMEOUT_MS.
|
||||||
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
||||||
body, err := json.Marshal(report)
|
body, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package rules reads the rule files: the plain text files in
|
// Package rules reads the rule files: the plain text files in
|
||||||
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
|
// SWW_RULES_DIR, one rule to a line, that each request is checked
|
||||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||||
// at start, and again once the directory has had no change for a short
|
// at start, and again once the directory has had no change for a short
|
||||||
// time after one is edited, added or removed.
|
// time after one is edited, added or removed.
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// extension ends the name of every rule file.
|
// extension ends the name of every rule file.
|
||||||
const extension = ".rules"
|
const extension = ".rules"
|
||||||
|
|
||||||
// quietTime is how long SWW_WAF_RULES_DIR must go without a change before
|
// quietTime is how long SWW_RULES_DIR must go without a change before
|
||||||
// the rule files are read again, so that a file still being written, such
|
// the rule files are read again, so that a file still being written, such
|
||||||
// as one saved in place, appended to or copied in with scp, is read only
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
// once whole.
|
// once whole.
|
||||||
@@ -100,9 +100,9 @@ type Rule struct {
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
|
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
|
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
||||||
// and no rule loaded.
|
// and no rule loaded.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
// ProcessLog receives how many rules were read, and the error in a
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
|||||||
func read(dir string) ([]Rule, string, error) {
|
func read(dir string) ([]Rule, string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dir, fmt.Errorf("SWW_WAF_RULES_DIR cannot be read: %w", err)
|
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []Rule
|
var rules []Rule
|
||||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
|||||||
// readFile appends the rules of the rule file at path to rules. places
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
// are where each id read so far is, and gain those of the file.
|
// are where each id read so far is, and gain those of the file.
|
||||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|||||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
|
// SWW_RULES_DIR does not exist, which would stop the start.
|
||||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
params.Enabled = false
|
params.Enabled = false
|
||||||
|
|
||||||
|
|||||||
+157
-464
@@ -1,23 +1,13 @@
|
|||||||
// Package state keeps sww's state in JSON files, as the "Persistent
|
// Package state keeps sww's state in JSON files in
|
||||||
// state" section of SPEC.md requires and docs/state-files.md lists them.
|
// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
||||||
//
|
// and docs/state-files.md lists them:
|
||||||
// The state files are in SWW_WAF_STATE_DIR:
|
// bans.json holds the bans, clients.json each client's counters and
|
||||||
// - bans.json holds the bans;
|
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||||
// - clients.json holds each client's counters and history;
|
// last good copy of each list fetched from a URL, the DNSBL zones'
|
||||||
// - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today;
|
// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
|
||||||
// - alerts.json holds the cooldowns, the hour under way, the alerts
|
// cooldowns, the hour under way, the alerts waiting for each destination
|
||||||
// waiting for each destination and the anomaly counters.
|
// and the anomaly counters. Load
|
||||||
//
|
// reads them at start, Watch takes in an admin's edit of one while
|
||||||
// The cache files are in SWW_WAF_CACHE_DIR. Each keeps answers from an
|
|
||||||
// outside service, which are fetched again when the file is lost:
|
|
||||||
// - lookups.json holds GeoJS's answers;
|
|
||||||
// - lists.json holds the last try and last good copy of each list
|
|
||||||
// fetched from a URL;
|
|
||||||
// - dnsbl.json holds the DNSBL zones' verdicts;
|
|
||||||
// - abuseipdb-scores.json holds AbuseIPDB's scores;
|
|
||||||
// - routewatch.json holds routewatch's answers.
|
|
||||||
//
|
|
||||||
// Load reads them at start, Watch takes in an admin's edit of one while
|
|
||||||
// sww runs, and Run and WriteAll write them. The disk is read and
|
// sww runs, and Run and WriteAll write them. The disk is read and
|
||||||
// written outside the parts' locks, which are held only to take a
|
// written outside the parts' locks, which are held only to take a
|
||||||
// snapshot or to put in what a file holds, so that no request waits on
|
// snapshot or to put in what a file holds, so that no request waits on
|
||||||
@@ -41,7 +31,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cloud.google.com/go/civil"
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
@@ -50,7 +39,6 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is the version of the files' format, the only one read.
|
// version is the version of the files' format, the only one read.
|
||||||
@@ -60,42 +48,15 @@ const version = 1
|
|||||||
// hold visitors' addresses.
|
// hold visitors' addresses.
|
||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
// The state files' names, in Dir.
|
// The state files' names.
|
||||||
const (
|
const (
|
||||||
bansJSON = "bans.json"
|
bansJSON = "bans.json"
|
||||||
clientsJSON = "clients.json"
|
clientsJSON = "clients.json"
|
||||||
abuseIPDBChecksSpentJSON = "abuseipdb-checks-spent.json"
|
lookupsJSON = "lookups.json"
|
||||||
alertsJSON = "alerts.json"
|
reputationJSON = "reputation.json"
|
||||||
|
alertsJSON = "alerts.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The cache files' names, in CacheDir.
|
|
||||||
const (
|
|
||||||
lookupsJSON = "lookups.json"
|
|
||||||
listsJSON = "lists.json"
|
|
||||||
dnsblJSON = "dnsbl.json"
|
|
||||||
abuseIPDBScoresJSON = "abuseipdb-scores.json"
|
|
||||||
routeWatchJSON = "routewatch.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fileNames returns the names of the state files, then those of the
|
|
||||||
// cache files, in the order they are written.
|
|
||||||
func fileNames() []string {
|
|
||||||
return []string{
|
|
||||||
bansJSON, clientsJSON, abuseIPDBChecksSpentJSON, alertsJSON,
|
|
||||||
lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCacheFile reports whether name is the name of a cache file.
|
|
||||||
func isCacheFile(name string) bool {
|
|
||||||
switch name {
|
|
||||||
case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errVersion = errors.New("unknown version")
|
errVersion = errors.New("unknown version")
|
||||||
// errMissing is for an entry without a field it needs.
|
// errMissing is for an entry without a field it needs.
|
||||||
@@ -111,29 +72,24 @@ var (
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the state files (SWW_WAF_STATE_DIR), and
|
// Dir is the directory of the state files (SWW_STATE_DIR).
|
||||||
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
|
Dir string
|
||||||
Dir string
|
|
||||||
CacheDir string
|
|
||||||
// WriteDelay is how long after a ban is made bans.json is written
|
// WriteDelay is how long after a ban is made bans.json is written
|
||||||
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
// (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file
|
||||||
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
|
// is (SWW_STATE_COUNTER_INTERVAL).
|
||||||
WriteDelay time.Duration
|
WriteDelay time.Duration
|
||||||
CounterInterval time.Duration
|
CounterInterval time.Duration
|
||||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
||||||
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
|
// hold the state. Alerts also receive a file_error alert for an edit set
|
||||||
// unset, and routewatch.json then holds no answers. Alerts also receive a
|
// aside, and for a write that fails while sww runs.
|
||||||
// file_error alert for an edit set aside, and for a write that fails while
|
Ledger *bans.Ledger
|
||||||
// sww runs.
|
Limiter *ratelimit.Limiter
|
||||||
Ledger *bans.Ledger
|
GeoJS *lookup.GeoJS
|
||||||
Limiter *ratelimit.Limiter
|
Lists *reputation.Lists
|
||||||
GeoJS *lookup.GeoJS
|
DNSBL *reputation.DNSBL
|
||||||
Lists *reputation.Lists
|
AbuseIPDB *reputation.AbuseIPDB
|
||||||
DNSBL *reputation.DNSBL
|
Alerts *alerts.Queue
|
||||||
AbuseIPDB *reputation.AbuseIPDB
|
Anomalies *anomaly.Counters
|
||||||
RouteWatch *routewatch.Client
|
|
||||||
Alerts *alerts.Queue
|
|
||||||
Anomalies *anomaly.Counters
|
|
||||||
// Now tells the time by which the counters' buckets run out, normally
|
// Now tells the time by which the counters' buckets run out, normally
|
||||||
// time.Now in UTC.
|
// time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -145,7 +101,7 @@ type Params struct {
|
|||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files are the state and cache files of a running sww.
|
// Files are the state files of a running sww.
|
||||||
type Files struct {
|
type Files struct {
|
||||||
params Params
|
params Params
|
||||||
|
|
||||||
@@ -191,38 +147,13 @@ type lookupsFile struct {
|
|||||||
Lookups []lookup.Answer `json:"lookups"`
|
Lookups []lookup.Answer `json:"lookups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// abuseIPDBChecksSpentFile is abuseipdb-checks-spent.json, indented for an
|
// reputationFile is reputation.json, indented for an admin to read and
|
||||||
// admin to read and edit.
|
// edit, so that each line of a list's copy is on a line of its own.
|
||||||
type abuseIPDBChecksSpentFile struct {
|
type reputationFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Day civil.Date `json:"day,omitzero"`
|
Lists []reputation.List `json:"lists"`
|
||||||
Spent int `json:"spent"`
|
Verdicts []reputation.Verdict `json:"verdicts"`
|
||||||
}
|
AbuseIPDB reputation.Checks `json:"abuseipdb"`
|
||||||
|
|
||||||
// listsFile is lists.json, indented for an admin to read and edit, so
|
|
||||||
// that each line of a list's copy is on a line of its own.
|
|
||||||
type listsFile struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Lists []reputation.List `json:"lists"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// dnsblFile is dnsbl.json, with each verdict on a line of its own.
|
|
||||||
type dnsblFile struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Verdicts []reputation.Verdict `json:"verdicts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// abuseIPDBScoresFile is abuseipdb-scores.json, with each score on a line
|
|
||||||
// of its own.
|
|
||||||
type abuseIPDBScoresFile struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Scores []reputation.Score `json:"scores"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeWatchFile is routewatch.json, with each answer on a line of its own.
|
|
||||||
type routeWatchFile struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Answers []routewatch.Answer `json:"answers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// alertsFile is alerts.json, indented for an admin to read and edit.
|
// alertsFile is alerts.json, indented for an admin to read and edit.
|
||||||
@@ -247,45 +178,34 @@ type stateFile interface {
|
|||||||
toUTC()
|
toUTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load checks that files can be written in Dir and CacheDir, and reads the
|
// Load checks that files can be written in Dir, and reads the state files
|
||||||
// files in them into the parts of Params that hold the state. A missing
|
// in it into the parts of Params that hold the state. A missing file is
|
||||||
// file is empty state, as on a first start. A state file that does not
|
// empty state, as on a first start. A file that does not parse, has an
|
||||||
// parse, has an unknown version, or has an entry without a field it
|
// unknown version, or has an entry without a field it needs, is an error
|
||||||
// needs, is an error that names the file and, where the JSON decoder
|
// that names the file and, where the JSON decoder tells it, the line and
|
||||||
// tells it, the line and column, or else the entry. A cache file is never
|
// column, or else the entry.
|
||||||
// an error, as readCache describes.
|
|
||||||
func Load(params Params) (*Files, error) {
|
func Load(params Params) (*Files, error) {
|
||||||
err := checkWritable(params.Dir)
|
err := checkWritable(params.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_STATE_DIR cannot be written: %w", err)
|
return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
err = checkWritable(params.CacheDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SWW_WAF_CACHE_DIR cannot be written: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||||
|
|
||||||
bansRead, bansErr := f.read(bansJSON)
|
bansRead, bansErr := f.read(bansJSON)
|
||||||
clientsRead, clientsErr := f.read(clientsJSON)
|
clientsRead, clientsErr := f.read(clientsJSON)
|
||||||
_, checksErr := f.read(abuseIPDBChecksSpentJSON)
|
lookupsRead, lookupsErr := f.read(lookupsJSON)
|
||||||
|
reputationRead, reputationErr := f.read(reputationJSON)
|
||||||
alertsRead, alertsErr := f.read(alertsJSON)
|
alertsRead, alertsErr := f.read(alertsJSON)
|
||||||
|
|
||||||
err = errors.Join(bansErr, clientsErr, checksErr, alertsErr)
|
err = errors.Join(bansErr, clientsErr, lookupsErr, reputationErr, alertsErr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lookupsRead := f.readCache(lookupsJSON)
|
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
||||||
listsRead := f.readCache(listsJSON)
|
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead,
|
||||||
f.readCache(dnsblJSON)
|
"lists", reputationRead, "alerts_waiting", alertsRead)
|
||||||
f.readCache(abuseIPDBScoresJSON)
|
|
||||||
f.readCache(routeWatchJSON)
|
|
||||||
|
|
||||||
params.ProcessLog.Info("read the state and cache files", "directory", params.Dir,
|
|
||||||
"cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead,
|
|
||||||
"lookups", lookupsRead, "lists", listsRead, "alerts_waiting", alertsRead)
|
|
||||||
|
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
@@ -314,101 +234,69 @@ func (f *Files) Run(ctx context.Context) {
|
|||||||
|
|
||||||
f.logFailure(bansJSON, f.writeFile(bansJSON))
|
f.logFailure(bansJSON, f.writeFile(bansJSON))
|
||||||
case <-interval.C:
|
case <-interval.C:
|
||||||
for _, name := range fileNames() {
|
for _, name := range []string{
|
||||||
|
bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON,
|
||||||
|
} {
|
||||||
f.logFailure(name, f.writeFile(name))
|
f.logFailure(name, f.writeFile(name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAll writes every state and cache file, as sww stops. A file that
|
// WriteAll writes every state file, as sww stops. A file that
|
||||||
// fails does not keep the others from being written.
|
// fails does not keep the others from being written.
|
||||||
func (f *Files) WriteAll() error {
|
func (f *Files) WriteAll() error {
|
||||||
names := fileNames()
|
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||||
errs := make([]error, 0, len(names))
|
f.writeFile(lookupsJSON), f.writeFile(reputationJSON), f.writeFile(alertsJSON))
|
||||||
|
|
||||||
for _, name := range names {
|
|
||||||
errs = append(errs, f.writeFile(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch watches Dir and CacheDir until ctx is done, and takes in an
|
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||||
// admin's edit of a file as soon as it is saved: what the file holds
|
// state file as soon as it is saved: what the file holds replaces what
|
||||||
// replaces what sww held for it. An edit that does not parse is left for
|
// sww held for it. An edit that does not parse is left for the
|
||||||
// the file's next write, which sets it aside, since a file can be read
|
// file's next write, which sets it aside, since a file can be read while
|
||||||
// while an editor is still writing it. A directory that cannot be watched
|
// an editor is still writing it. If Dir cannot be watched, that is
|
||||||
// is logged, and the other is still watched. An edit in a directory not
|
// logged, and an edit is taken in only before its file is written.
|
||||||
// watched is taken in only before its file is written.
|
|
||||||
func (f *Files) Watch(ctx context.Context) {
|
func (f *Files) Watch(ctx context.Context) {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer func() {
|
||||||
|
_ = watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(f.params.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("cannot watch the state and cache files for edits",
|
f.params.ProcessLog.Error("cannot watch the state files for edits",
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
f.params.ProcessLog.Info("watching the state files for edits",
|
||||||
_ = watcher.Close()
|
"directory", f.params.Dir)
|
||||||
}()
|
|
||||||
|
|
||||||
var watched []string
|
|
||||||
|
|
||||||
for _, dir := range []struct{ path, failed string }{
|
|
||||||
{f.params.Dir, "cannot watch the state files for edits"},
|
|
||||||
{f.params.CacheDir, "cannot watch the cache files for edits"},
|
|
||||||
} {
|
|
||||||
err = watcher.Add(dir.path)
|
|
||||||
if err != nil {
|
|
||||||
f.params.ProcessLog.Error(dir.failed, "directory", dir.path,
|
|
||||||
"error", err.Error())
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
watched = append(watched, dir.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
watching := "watching the state and cache files for edits"
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case len(watched) == 0:
|
|
||||||
return
|
|
||||||
case len(watched) == 1 && watched[0] == f.params.Dir:
|
|
||||||
watching = "watching the state files for edits"
|
|
||||||
case len(watched) == 1:
|
|
||||||
watching = "watching the cache files for edits"
|
|
||||||
}
|
|
||||||
|
|
||||||
f.params.ProcessLog.Info(watching, "directories", watched)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case event := <-watcher.Events:
|
case event := <-watcher.Events:
|
||||||
name := filepath.Base(event.Name)
|
switch name := filepath.Base(event.Name); name {
|
||||||
if slices.Contains(fileNames(), name) &&
|
case bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON:
|
||||||
filepath.Join(f.dir(name), name) == event.Name {
|
|
||||||
f.fileChanged(name)
|
f.fileChanged(name)
|
||||||
}
|
}
|
||||||
case err = <-watcher.Errors:
|
case err = <-watcher.Errors:
|
||||||
f.params.ProcessLog.Warn("watching the state and cache files failed",
|
f.params.ProcessLog.Warn("watching the state files failed",
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logFailure logs a write of the file name that failed, and raises
|
// logFailure logs a write of the state file name that failed, and raises
|
||||||
// a file_error alert for it.
|
// a file_error alert for it.
|
||||||
func (f *Files) logFailure(name string, err error) {
|
func (f *Files) logFailure(name string, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed := "writing the state files failed"
|
const failed = "writing the state files failed"
|
||||||
if isCacheFile(name) {
|
|
||||||
failed = "writing the cache files failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raised before it is logged, so that the alert is there once the
|
// Raised before it is logged, so that the alert is there once the
|
||||||
// log line is.
|
// log line is.
|
||||||
@@ -416,14 +304,14 @@ func (f *Files) logFailure(name string, err error) {
|
|||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: failed,
|
Reason: failed,
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": filepath.Join(f.dir(name), name), "error": err.Error(),
|
"file": filepath.Join(f.params.Dir, name), "error": err.Error(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(failed, "error", err.Error())
|
f.params.ProcessLog.Error(failed, "error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileChanged takes in what the file name holds, as Watch sees it
|
// fileChanged takes in what the state file name holds, as Watch sees it
|
||||||
// change, if that is an edit made since sww last read or wrote
|
// change, if that is an edit made since sww last read or wrote
|
||||||
// the file. A file that cannot be read or does not parse is left for its
|
// the file. A file that cannot be read or does not parse is left for its
|
||||||
// next write.
|
// next write.
|
||||||
@@ -439,7 +327,7 @@ func (f *Files) fileChanged(name string) {
|
|||||||
_ = f.takeInEdit(name, data)
|
_ = f.takeInEdit(name, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeInEdit takes in data, an edit of the file name, as takeIn
|
// takeInEdit takes in data, an edit of the state file name, as takeIn
|
||||||
// does, and counts and logs it. Every edit taken in while sww
|
// does, and counts and logs it. Every edit taken in while sww
|
||||||
// runs, by Watch or by a write, is taken in here. An edit that does not
|
// runs, by Watch or by a write, is taken in here. An edit that does not
|
||||||
// parse is neither counted nor logged, and takeIn's error returned.
|
// parse is neither counted nor logged, and takeIn's error returned.
|
||||||
@@ -449,31 +337,17 @@ func (f *Files) takeInEdit(name string, data []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tookIn := "took in an edit of a state file"
|
|
||||||
if isCacheFile(name) {
|
|
||||||
tookIn = "took in an edit of a cache file"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counted before it is logged, so that the count is there once the
|
// Counted before it is logged, so that the count is there once the
|
||||||
// log line is.
|
// log line is.
|
||||||
f.params.Metrics.StateFileEditTakenIn(name)
|
f.params.Metrics.StateFileEditTakenIn(name)
|
||||||
f.params.ProcessLog.Info(tookIn, "file", filepath.Join(f.dir(name), name))
|
f.params.ProcessLog.Info("took in an edit of a state file",
|
||||||
|
"file", filepath.Join(f.params.Dir, name))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dir returns the directory of the file name: CacheDir for a cache file,
|
// read takes in the state file name at start, and returns how many
|
||||||
// and Dir for a state file.
|
// entries it holds. A missing file holds none.
|
||||||
func (f *Files) dir(name string) string {
|
|
||||||
if isCacheFile(name) {
|
|
||||||
return f.params.CacheDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.params.Dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// read takes in the file name at start, and returns how many entries it
|
|
||||||
// holds. A missing file holds none.
|
|
||||||
func (f *Files) read(name string) (int, error) {
|
func (f *Files) read(name string) (int, error) {
|
||||||
data, changed, err := f.readChanged(name)
|
data, changed, err := f.readChanged(name)
|
||||||
if err != nil || !changed {
|
if err != nil || !changed {
|
||||||
@@ -483,44 +357,14 @@ func (f *Files) read(name string) (int, error) {
|
|||||||
return f.takeIn(name, data, false)
|
return f.takeIn(name, data, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readCache takes in the cache file name at start, as read does. A cache
|
// readChanged returns what the state file name holds, and whether that
|
||||||
// file that cannot be read, or does not parse, does not stop the start:
|
// has changed since sww last read or wrote the file, as it has
|
||||||
// it is logged, and its cache starts empty and fills again. One that does
|
// for a file sww never read or wrote. A missing file has not
|
||||||
// not parse is set aside as an edit is, and one that cannot be read is
|
// changed: it is written again at its next write.
|
||||||
// written over at its next write.
|
|
||||||
func (f *Files) readCache(name string) int {
|
|
||||||
data, changed, err := f.readChanged(name)
|
|
||||||
if err != nil || !changed {
|
|
||||||
f.logUnread(name, err)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := f.takeIn(name, data, false)
|
|
||||||
if err != nil {
|
|
||||||
f.logUnread(name, f.setAside(name, err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// logUnread logs err, the reason the cache file name was not read at
|
|
||||||
// start, unless it is nil.
|
|
||||||
func (f *Files) logUnread(name string, err error) {
|
|
||||||
if err != nil {
|
|
||||||
f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty",
|
|
||||||
"file", filepath.Join(f.dir(name), name), "error", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readChanged returns what the file name holds, and whether that has
|
|
||||||
// changed since sww last read or wrote the file, as it has for a file
|
|
||||||
// sww never read or wrote. A missing file has not changed: it is written
|
|
||||||
// again at its next write.
|
|
||||||
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||||
path := filepath.Join(f.dir(name), name)
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories
|
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
@@ -532,39 +376,15 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
|||||||
return data, sha256.Sum256(data) != f.sums[name], nil
|
return data, sha256.Sum256(data) != f.sums[name], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeIn parses data, what the file name holds, puts it into the
|
// takeIn parses data, what the state file name holds, puts it into the
|
||||||
// part that keeps that state, in place of what the part held, and returns
|
// part that keeps that state, in place of what the part held, and returns
|
||||||
// how many entries the file holds. edit is whether data is an admin's
|
// how many entries the file holds. edit is whether data is an admin's
|
||||||
// edit taken in while sww runs, rather than the file read at the
|
// edit taken in while sww runs, rather than the file read at the
|
||||||
// start. An error names the file and, where the JSON decoder tells it,
|
// start. An error names the file and, where the JSON decoder tells it,
|
||||||
// the line and column, or else the entry. abuseipdb-checks-spent.json
|
// the line and column, or else the entry.
|
||||||
// holds no entries.
|
|
||||||
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
||||||
path := filepath.Join(f.dir(name), name)
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
var (
|
|
||||||
entries int
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
if isCacheFile(name) {
|
|
||||||
entries, err = f.takeInCache(name, path, data)
|
|
||||||
} else {
|
|
||||||
entries, err = f.takeInState(name, path, data, edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.sums[name] = sha256.Sum256(data)
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeInState parses data, what the state file name, at path, holds, and
|
|
||||||
// puts it in, as takeIn describes.
|
|
||||||
func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) {
|
|
||||||
var entries int
|
var entries int
|
||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
@@ -598,33 +418,6 @@ func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, err
|
|||||||
|
|
||||||
f.params.Limiter.Load(file.Clients, f.params.Now())
|
f.params.Limiter.Load(file.Clients, f.params.Now())
|
||||||
entries = len(file.Clients)
|
entries = len(file.Clients)
|
||||||
case abuseIPDBChecksSpentJSON:
|
|
||||||
var file abuseIPDBChecksSpentFile
|
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{
|
|
||||||
Day: file.Day, Spent: file.Spent,
|
|
||||||
})
|
|
||||||
case alertsJSON:
|
|
||||||
waiting, err := f.takeInAlerts(path, data)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = waiting
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeInCache parses data, what the cache file name, at path, holds, and
|
|
||||||
// puts it in, as takeIn describes.
|
|
||||||
func (f *Files) takeInCache(name, path string, data []byte) (int, error) {
|
|
||||||
switch name {
|
|
||||||
case lookupsJSON:
|
case lookupsJSON:
|
||||||
var file lookupsFile
|
var file lookupsFile
|
||||||
|
|
||||||
@@ -634,10 +427,9 @@ func (f *Files) takeInCache(name, path string, data []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
f.params.GeoJS.Load(file.Lookups)
|
f.params.GeoJS.Load(file.Lookups)
|
||||||
|
entries = len(file.Lookups)
|
||||||
return len(file.Lookups), nil
|
case reputationJSON:
|
||||||
case listsJSON:
|
var file reputationFile
|
||||||
var file listsFile
|
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
err := parse(path, data, &file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -649,43 +441,21 @@ func (f *Files) takeInCache(name, path string, data []byte) (int, error) {
|
|||||||
return 0, fmt.Errorf("%s: %w", path, err)
|
return 0, fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(file.Lists), nil
|
|
||||||
case dnsblJSON:
|
|
||||||
var file dnsblFile
|
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.params.DNSBL.Load(file.Verdicts)
|
f.params.DNSBL.Load(file.Verdicts)
|
||||||
|
f.params.AbuseIPDB.Load(file.AbuseIPDB)
|
||||||
return len(file.Verdicts), nil
|
entries = len(file.Lists)
|
||||||
case routeWatchJSON:
|
case alertsJSON:
|
||||||
var file routeWatchFile
|
waiting, err := f.takeInAlerts(path, data)
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.params.RouteWatch != nil {
|
entries = waiting
|
||||||
f.params.RouteWatch.Load(file.Answers)
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(file.Answers), nil
|
|
||||||
default: // abuseipdb-scores.json
|
|
||||||
var file abuseIPDBScoresFile
|
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.params.AbuseIPDB.Load(file.Scores)
|
|
||||||
|
|
||||||
return len(file.Scores), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
f.sums[name] = sha256.Sum256(data)
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeInAlerts parses data, what alerts.json, at path, holds, puts it
|
// takeInAlerts parses data, what alerts.json, at path, holds, puts it
|
||||||
@@ -723,22 +493,17 @@ func (f *Files) takeInAlerts(path string, data []byte) (int, error) {
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFile writes the file name from what sww holds. An edit made since
|
// writeFile writes the state file name from what sww holds. An
|
||||||
// sww last read or wrote the file is taken in first, so that it is not
|
// edit made since sww last read or wrote the file is taken in
|
||||||
// overwritten, or set aside if it does not parse. A state file that
|
// first, so that it is not overwritten, or set aside if it does not
|
||||||
// cannot be read, or an edit that cannot be set aside, is left as it is,
|
// parse. A file that cannot be read, or an edit that cannot be set
|
||||||
// and the write given up. A cache file that cannot be read is written
|
// aside, is left as it is, and the write given up. Every write is counted
|
||||||
// over, since what it held is fetched again. Every write is counted in
|
// in the metrics, and one that fails or is given up as a failure.
|
||||||
// the metrics, and one that fails or is given up as a failure.
|
|
||||||
func (f *Files) writeFile(name string) error {
|
func (f *Files) writeFile(name string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
data, changed, err := f.readChanged(name)
|
data, changed, err := f.readChanged(name)
|
||||||
if err != nil && isCacheFile(name) {
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil && changed {
|
if err == nil && changed {
|
||||||
err = f.takeInEdit(name, data)
|
err = f.takeInEdit(name, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -754,7 +519,7 @@ func (f *Files) writeFile(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = write(f.dir(name), name, data)
|
err = write(f.params.Dir, name, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -762,7 +527,7 @@ func (f *Files) writeFile(name string) error {
|
|||||||
// fails, so that its next read does not take it for an admin's
|
// fails, so that its next read does not take it for an admin's
|
||||||
// edit.
|
// edit.
|
||||||
f.sums[name] = sha256.Sum256(data)
|
f.sums[name] = sha256.Sum256(data)
|
||||||
err = syncDirectory(f.dir(name))
|
err = syncDirectory(f.params.Dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.params.Metrics.StateFileWritten(name, len(data), err)
|
f.params.Metrics.StateFileWritten(name, len(data), err)
|
||||||
@@ -770,23 +535,20 @@ func (f *Files) writeFile(name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAside renames the file name, an edit that does not parse with
|
// setAside renames the state file name, an edit that does not parse with
|
||||||
// parseErr, to name.bad, for the admin to mend, and logs it with where in
|
// parseErr, to name.bad, for the admin to mend, and logs it with where in
|
||||||
// the file the error is, and raises a file_error alert for it. If the
|
// the file the error is, and raises a file_error alert for it. If the
|
||||||
// rename fails, the edit is left as it is, and the error returned is
|
// rename fails, the edit is left as it is, and the error returned is
|
||||||
// parseErr joined with the rename's.
|
// parseErr joined with the rename's.
|
||||||
func (f *Files) setAside(name string, parseErr error) error {
|
func (f *Files) setAside(name string, parseErr error) error {
|
||||||
path := filepath.Join(f.dir(name), name)
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
err := os.Rename(path, path+".bad")
|
err := os.Rename(path, path+".bad")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Join(parseErr, err)
|
return errors.Join(parseErr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
setAside := "set aside an edit of a state file that does not parse"
|
const setAside = "set aside an edit of a state file that does not parse"
|
||||||
if isCacheFile(name) {
|
|
||||||
setAside = "set aside an edit of a cache file that does not parse"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raised before it is logged, so that the alert is there once the log
|
// Raised before it is logged, so that the alert is there once the log
|
||||||
// line is.
|
// line is.
|
||||||
@@ -801,7 +563,7 @@ func (f *Files) setAside(name string, parseErr error) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encode returns the file name as sww writes it, from a
|
// encode returns the state file name as sww writes it, from a
|
||||||
// snapshot of the part that keeps that state.
|
// snapshot of the part that keeps that state.
|
||||||
func (f *Files) encode(name string) ([]byte, error) {
|
func (f *Files) encode(name string) ([]byte, error) {
|
||||||
switch name {
|
switch name {
|
||||||
@@ -813,24 +575,10 @@ func (f *Files) encode(name string) ([]byte, error) {
|
|||||||
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||||
case lookupsJSON:
|
case lookupsJSON:
|
||||||
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||||
case listsJSON:
|
case reputationJSON:
|
||||||
return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()})
|
return encodeIndented(reputationFile{
|
||||||
case dnsblJSON:
|
Version: version, Lists: f.params.Lists.Snapshot(),
|
||||||
return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot())
|
Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(),
|
||||||
case abuseIPDBScoresJSON:
|
|
||||||
return encodeOnePerLine("scores", f.params.AbuseIPDB.Snapshot())
|
|
||||||
case routeWatchJSON:
|
|
||||||
var answers []routewatch.Answer
|
|
||||||
if f.params.RouteWatch != nil {
|
|
||||||
answers = f.params.RouteWatch.Snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodeOnePerLine("answers", answers)
|
|
||||||
case abuseIPDBChecksSpentJSON:
|
|
||||||
checks := f.params.AbuseIPDB.ChecksSpent()
|
|
||||||
|
|
||||||
return encodeIndented(abuseIPDBChecksSpentFile{
|
|
||||||
Version: version, Day: checks.Day, Spent: checks.Spent,
|
|
||||||
})
|
})
|
||||||
default: // alerts.json
|
default: // alerts.json
|
||||||
held := f.params.Alerts.Snapshot()
|
held := f.params.Alerts.Snapshot()
|
||||||
@@ -1000,8 +748,14 @@ func (f *lookupsFile) check(data []byte) error {
|
|||||||
// check refuses a list without its URL, which would name no list, or the
|
// check refuses a list without its URL, which would name no list, or the
|
||||||
// time it was last tried, which would have it fetched at once, and a copy
|
// time it was last tried, which would have it fetched at once, and a copy
|
||||||
// of it without the time it was fetched, or without its lines, which hold
|
// of it without the time it was fetched, or without its lines, which hold
|
||||||
// the list.
|
// the list. It refuses a verdict without its zone or its client, which
|
||||||
func (f *listsFile) check([]byte) error {
|
// would be about no one, whether the zone lists the client, or the time
|
||||||
|
// it was fetched, which would drop it, and so an AbuseIPDB score without
|
||||||
|
// its client, the score, or the time it was fetched. A verdict's listed is
|
||||||
|
// false for a client the zone does not list, and a score can be 0, which
|
||||||
|
// the structs cannot tell from a missing one, so each is read again as
|
||||||
|
// written.
|
||||||
|
func (f *reputationFile) check(data []byte) error {
|
||||||
for i, kept := range f.Lists {
|
for i, kept := range f.Lists {
|
||||||
switch {
|
switch {
|
||||||
case kept.URL == "":
|
case kept.URL == "":
|
||||||
@@ -1015,15 +769,6 @@ func (f *listsFile) check([]byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// check refuses a verdict without its zone or its client, which would be
|
|
||||||
// about no one, whether the zone lists the client, or the time it was
|
|
||||||
// fetched, which would drop it. A verdict's listed is false for a client
|
|
||||||
// the zone does not list, which Verdict cannot tell from a missing one,
|
|
||||||
// so each listed is read again as written.
|
|
||||||
func (f *dnsblFile) check(data []byte) error {
|
|
||||||
var written struct {
|
var written struct {
|
||||||
Verdicts []struct {
|
Verdicts []struct {
|
||||||
Listed *bool `json:"listed"`
|
Listed *bool `json:"listed"`
|
||||||
@@ -1038,27 +783,28 @@ func (f *dnsblFile) check(data []byte) error {
|
|||||||
for i, verdict := range f.Verdicts {
|
for i, verdict := range f.Verdicts {
|
||||||
switch {
|
switch {
|
||||||
case verdict.Zone == "":
|
case verdict.Zone == "":
|
||||||
return missing(i, "zone")
|
return fmt.Errorf("verdicts %w", missing(i, "zone"))
|
||||||
case !verdict.Client.IsValid():
|
case !verdict.Client.IsValid():
|
||||||
return missing(i, "client")
|
return fmt.Errorf("verdicts %w", missing(i, "client"))
|
||||||
case written.Verdicts[i].Listed == nil:
|
case written.Verdicts[i].Listed == nil:
|
||||||
return missing(i, "listed")
|
return fmt.Errorf("verdicts %w", missing(i, "listed"))
|
||||||
case verdict.Fetched.IsZero():
|
case verdict.Fetched.IsZero():
|
||||||
return missing(i, "fetched")
|
return fmt.Errorf("verdicts %w", missing(i, "fetched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return checkScores(f.AbuseIPDB.Scores, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check refuses an AbuseIPDB score without its client, the score, or the
|
// checkScores refuses an AbuseIPDB score, of scores, read from data, as
|
||||||
// time it was fetched. A score can be 0, which Score cannot tell from a
|
// reputationFile's check describes.
|
||||||
// missing one, so each score is read again as written.
|
func checkScores(scores []reputation.Score, data []byte) error {
|
||||||
func (f *abuseIPDBScoresFile) check(data []byte) error {
|
|
||||||
var written struct {
|
var written struct {
|
||||||
Scores []struct {
|
AbuseIPDB struct {
|
||||||
Score *int64 `json:"score"`
|
Scores []struct {
|
||||||
} `json:"scores"`
|
Score *int64 `json:"score"`
|
||||||
|
} `json:"scores"`
|
||||||
|
} `json:"abuseipdb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal(data, &written)
|
err := json.Unmarshal(data, &written)
|
||||||
@@ -1066,57 +812,20 @@ func (f *abuseIPDBScoresFile) check(data []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, kept := range f.Scores {
|
for i, kept := range scores {
|
||||||
switch {
|
switch {
|
||||||
case !kept.Client.IsValid():
|
case !kept.Client.IsValid():
|
||||||
return missing(i, "client")
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "client"))
|
||||||
case written.Scores[i].Score == nil:
|
case written.AbuseIPDB.Scores[i].Score == nil:
|
||||||
return missing(i, "score")
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "score"))
|
||||||
case kept.Fetched.IsZero():
|
case kept.Fetched.IsZero():
|
||||||
return missing(i, "fetched")
|
return fmt.Errorf("abuseipdb scores %w", missing(i, "fetched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check refuses an answer without its client, which would answer for
|
|
||||||
// nobody, its netblock, or the time routewatch gave it, which would drop
|
|
||||||
// it. "" is the netblock of an address without a live route, which Answer
|
|
||||||
// cannot tell from a missing one, so each netblock is read again as
|
|
||||||
// written.
|
|
||||||
func (f *routeWatchFile) check(data []byte) error {
|
|
||||||
var written struct {
|
|
||||||
Answers []struct {
|
|
||||||
Netblock *string `json:"netblock"`
|
|
||||||
} `json:"answers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal(data, &written)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, answer := range f.Answers {
|
|
||||||
switch {
|
|
||||||
case !answer.Client.IsValid():
|
|
||||||
return missing(i, "client")
|
|
||||||
case written.Answers[i].Netblock == nil:
|
|
||||||
return missing(i, "netblock")
|
|
||||||
case answer.Answered.IsZero():
|
|
||||||
return missing(i, "answered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// check refuses nothing, since a missing day or count means no check
|
|
||||||
// spent.
|
|
||||||
func (f *abuseIPDBChecksSpentFile) check([]byte) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// check refuses a cooldown without its event or when its alert was sent,
|
// check refuses a cooldown without its event or when its alert was sent,
|
||||||
// which would hold back no repeat, alerts waiting for a destination with
|
// which would hold back no repeat, alerts waiting for a destination with
|
||||||
// another name than webhook, slack or ntfy, most likely misspelt, an
|
// another name than webhook, slack or ntfy, most likely misspelt, an
|
||||||
@@ -1238,37 +947,22 @@ func (f *lookupsFile) toUTC() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toUTC converts both times of each list to UTC.
|
// toUTC converts every time of each list, verdict and AbuseIPDB score to
|
||||||
func (f *listsFile) toUTC() {
|
// UTC.
|
||||||
|
func (f *reputationFile) toUTC() {
|
||||||
for i := range f.Lists {
|
for i := range f.Lists {
|
||||||
convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched)
|
convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// toUTC converts the time of each verdict to UTC.
|
|
||||||
func (f *dnsblFile) toUTC() {
|
|
||||||
for i := range f.Verdicts {
|
for i := range f.Verdicts {
|
||||||
convertToUTC(&f.Verdicts[i].Fetched)
|
convertToUTC(&f.Verdicts[i].Fetched)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// toUTC converts the time of each AbuseIPDB score to UTC.
|
for i := range f.AbuseIPDB.Scores {
|
||||||
func (f *abuseIPDBScoresFile) toUTC() {
|
convertToUTC(&f.AbuseIPDB.Scores[i].Fetched)
|
||||||
for i := range f.Scores {
|
|
||||||
convertToUTC(&f.Scores[i].Fetched)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toUTC converts the time of each answer to UTC.
|
|
||||||
func (f *routeWatchFile) toUTC() {
|
|
||||||
for i := range f.Answers {
|
|
||||||
convertToUTC(&f.Answers[i].Answered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toUTC does nothing: abuseipdb-checks-spent.json holds a day, not a time.
|
|
||||||
func (f *abuseIPDBChecksSpentFile) toUTC() {}
|
|
||||||
|
|
||||||
// toUTC converts every time of each cooldown, of the hour, of each alert
|
// toUTC converts every time of each cooldown, of the hour, of each alert
|
||||||
// waiting and of each anomaly counter to UTC.
|
// waiting and of each anomaly counter to UTC.
|
||||||
func (f *alertsFile) toUTC() {
|
func (f *alertsFile) toUTC() {
|
||||||
@@ -1420,8 +1114,7 @@ func write(dir, name string, data []byte) error {
|
|||||||
|
|
||||||
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
||||||
func syncDirectory(dir string) error {
|
func syncDirectory(dir string) error {
|
||||||
//nolint:gosec // SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR itself
|
directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself
|
||||||
directory, err := os.Open(dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1124,7 @@ func syncDirectory(dir string) error {
|
|||||||
|
|
||||||
// writeSynced writes data to the file at path, and syncs it to the disk.
|
// writeSynced writes data to the file at path, and syncs it to the disk.
|
||||||
func writeSynced(path string, data []byte) error {
|
func writeSynced(path string, data []byte) error {
|
||||||
//nolint:gosec // a file's temporary file, in SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR
|
//nolint:gosec // a state file's temporary file, in SWW_STATE_DIR
|
||||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+175
-598
File diff suppressed because it is too large.
Load diff
+15
-13
@@ -10,8 +10,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
@@ -23,17 +21,17 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
|
|||||||
|
|
||||||
// HealthCheck is the container's health check. In waf's container, where
|
// HealthCheck is the container's health check. In waf's container, where
|
||||||
// SWW_COMMAND is waf, it returns 0 while waf answers its health
|
// SWW_COMMAND is waf, it returns 0 while waf answers its health
|
||||||
// endpoint on 127.0.0.1, at the port in SWW_WAF_LISTEN_ADDR. In rproxy's container,
|
// 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
|
// 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 SWW_RPROXY_LISTEN_ADDR, and
|
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
|
||||||
// the app accepts connections at the address in SWW_RPROXY_UPSTREAM_URL. Otherwise,
|
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
|
||||||
// any other value of SWW_COMMAND included, it writes why to stderr and
|
// any other value of SWW_COMMAND included, it writes why to stderr and
|
||||||
// returns 1. It reads no other setting, nor a file that another names, so neither
|
// returns 1. It reads no other setting, nor a file that another names, so neither
|
||||||
// can fail it.
|
// can fail it.
|
||||||
// args are the arguments after `healthcheck`; it takes none, and given
|
// args are the arguments after `healthcheck`; it takes none, and given
|
||||||
// one it names it on stderr and returns 1 without checking anything.
|
// one it names it on stderr and returns 1 without checking anything.
|
||||||
func HealthCheck(
|
func HealthCheck(
|
||||||
ctx context.Context, args []string, v *viper.Viper,
|
ctx context.Context, args []string, lookupEnv func(string) (string, bool),
|
||||||
stderr io.Writer,
|
stderr io.Writer,
|
||||||
) int {
|
) int {
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -46,15 +44,15 @@ func HealthCheck(
|
|||||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
command, err := config.Command(v)
|
command, err := config.Command(lookupEnv)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
err = fmt.Errorf("invalid setting: %w", err)
|
err = fmt.Errorf("invalid setting: %w", err)
|
||||||
case command == "waf":
|
case command == "waf":
|
||||||
err = checkWAF(ctx, v)
|
err = checkWAF(ctx, lookupEnv)
|
||||||
default:
|
default:
|
||||||
err = checkRProxyAndApp(ctx, v)
|
err = checkRProxyAndApp(ctx, lookupEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,8 +65,10 @@ func HealthCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkWAF asks waf's health endpoint.
|
// checkWAF asks waf's health endpoint.
|
||||||
func checkWAF(ctx context.Context, v *viper.Viper) error {
|
func checkWAF(
|
||||||
listenAddr, err := config.WAFListenAddr(v)
|
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||||
|
) error {
|
||||||
|
listenAddr, err := config.WAFListenAddr(lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid setting: %w", err)
|
return fmt.Errorf("invalid setting: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,8 +82,10 @@ func checkWAF(ctx context.Context, v *viper.Viper) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
|
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
|
||||||
func checkRProxyAndApp(ctx context.Context, v *viper.Viper) error {
|
func checkRProxyAndApp(
|
||||||
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v)
|
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||||
|
) error {
|
||||||
|
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid setting: %w", err)
|
return fmt.Errorf("invalid setting: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
|
|||||||
exited <- run(ctx, settings, out)
|
exited <- run(ctx, settings, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
_, port, _ := net.SplitHostPort(addr)
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
|
||||||
// The container's settings: an address to listen on with an empty
|
// The container's settings: an address to listen on with an empty
|
||||||
@@ -55,9 +54,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
|
|||||||
// or invalid token file, or an invalid value of another setting, does not
|
// or invalid token file, or an invalid value of another setting, does not
|
||||||
// fail it.
|
// fail it.
|
||||||
for _, other := range []struct{ name, value string }{
|
for _, other := range []struct{ name, value string }{
|
||||||
{"SWW_WAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
{"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
||||||
{"SWW_WAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
{"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
||||||
{"SWW_WAF_MODE", "neither"},
|
{"SWW_MODE", "neither"},
|
||||||
} {
|
} {
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: wafCommand,
|
commandSetting: wafCommand,
|
||||||
@@ -73,7 +72,7 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
|
|||||||
}, 1, "unhealthy: ask waf: ")
|
}, 1, "unhealthy: ask waf: ")
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: wafCommand, listenAddr: "8080",
|
commandSetting: wafCommand, listenAddr: "8080",
|
||||||
}, 1, "unhealthy: invalid setting: SWW_WAF_LISTEN_ADDR: ")
|
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
|
||||||
|
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
@@ -112,8 +111,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
0, "")
|
0, "")
|
||||||
|
|
||||||
// Another setting does not fail it, even an invalid one, and neither does
|
// Another setting does not fail it, even an invalid one, and neither does
|
||||||
// SWW_RPROXY_WAF_BACKEND being unset.
|
// SWRP_WAF_BACKEND being unset.
|
||||||
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
|
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
|
||||||
|
|
||||||
// Another test can take a closed server's port at once, so the app's
|
// Another test can take a closed server's port at once, so the app's
|
||||||
// address here is a multicast address, to which TCP cannot connect.
|
// address here is a multicast address, to which TCP cannot connect.
|
||||||
@@ -124,14 +123,13 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
// itself.
|
// itself.
|
||||||
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
|
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
|
||||||
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
|
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
|
||||||
"unhealthy: invalid setting: SWW_RPROXY_LISTEN_ADDR: ")
|
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ")
|
||||||
|
|
||||||
// A setting of rproxy's can be given as a file too, here the listen
|
// No setting of rproxy's has a form given as a file.
|
||||||
// address, which the health check reads.
|
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||||
rproxyUpstreamURL: app.URL,
|
rproxyUpstreamURL: app.URL,
|
||||||
}, 0, "")
|
}, 1, "unhealthy: ask rproxy: ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
||||||
@@ -147,7 +145,11 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
|||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
got := sww.HealthCheck(t.Context(), []string{"now"}, configtest.Viper(nil), &stderr)
|
noSettings := func(string) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
|
||||||
|
|
||||||
want := "sww healthcheck: unexpected argument \"now\"\n"
|
want := "sww healthcheck: unexpected argument \"now\"\n"
|
||||||
if got != 1 || stderr.String() != want {
|
if got != 1 || stderr.String() != want {
|
||||||
@@ -164,7 +166,11 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
|
|||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
got := sww.HealthCheck(t.Context(), nil, configtest.Viper(env), &stderr)
|
got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
}, &stderr)
|
||||||
|
|
||||||
wrote := stderr.String()
|
wrote := stderr.String()
|
||||||
if got != status || !strings.HasPrefix(wrote, message) ||
|
if got != status || !strings.HasPrefix(wrote, message) ||
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||||
// requests until ctx is done. Then it gives the requests in progress
|
// requests until ctx is done. Then it gives the requests in progress
|
||||||
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
|
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
|
||||||
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
||||||
// messages name the host's name as instance, and it writes those at info or
|
// messages name the host's name as instance, and it writes those at info or
|
||||||
// more severe: rproxy has no setting for either.
|
// more severe: rproxy has no setting for either.
|
||||||
@@ -23,7 +23,7 @@ func RunRProxy(ctx context.Context, params Params) int {
|
|||||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
|
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(params.Viper)
|
cfg, err := config.RProxyFromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("invalid setting", "error", err.Error())
|
processLog.Error("invalid setting", "error", err.Error())
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ func RunRProxy(ctx context.Context, params Params) int {
|
|||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot listen on SWW_RPROXY_LISTEN_ADDR", "error", err.Error())
|
processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-50
@@ -19,20 +19,19 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// wafBackend is the setting that names waf to rproxy.
|
// wafBackend is the setting that names waf to rproxy.
|
||||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
// rproxyListenAddr is the setting that says where rproxy listens.
|
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||||
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||||
// rproxyUpstreamURL is the setting that says where the app is.
|
// rproxyUpstreamURL is the setting that says where the app is.
|
||||||
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
||||||
// rproxyTimeout is the setting that says how long rproxy waits for waf.
|
// rproxyTimeout is the setting that says how long rproxy waits for waf.
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
// bannedProbe is a path the default rule file bans a client for.
|
// bannedProbe is a path the default rule file bans a client for.
|
||||||
bannedProbe = "/.env"
|
bannedProbe = "/.env"
|
||||||
// otherClient is a client the tests do not ban.
|
// otherClient is a client the tests do not ban.
|
||||||
@@ -44,8 +43,12 @@ const (
|
|||||||
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
||||||
return sww.RunRProxy(ctx, sww.Params{
|
return sww.RunRProxy(ctx, sww.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
Viper: configtest.Viper(env),
|
LookupEnv: func(name string) (string, bool) {
|
||||||
Stdout: out,
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
},
|
||||||
|
Stdout: out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,15 +64,13 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
exited <- runRProxy(ctx, map[string]string{
|
exited <- runRProxy(ctx, map[string]string{
|
||||||
rproxyListenAddr: localhost + ":0",
|
rproxyListenAddr: localhost + ":0",
|
||||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
"SWW_RPROXY_TRUSTED_PROXIES": localhost + "/32",
|
"SWRP_TRUSTED_PROXIES": localhost + "/32",
|
||||||
wafBackend: backend,
|
wafBackend: backend,
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
|||||||
out.line(t, "msg", "stopped")
|
out.line(t, "msg", "stopped")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
return "http://" + addr, out
|
return "http://" + addr, out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ func startWAF(t *testing.T, env map[string]string) (string, *output, func()) {
|
|||||||
exited <- run(ctx, env, out)
|
exited <- run(ctx, env, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
return addr, out, func() {
|
return addr, out, func() {
|
||||||
stop()
|
stop()
|
||||||
@@ -504,10 +507,9 @@ func TestAStatusWAFDoesNotTakeIsReportedAs599(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startRProxyProcess runs `sww rproxy` in a process of its own, with the
|
// startRProxyProcess runs `sww rproxy` in a process of its own, with the
|
||||||
// settings in env, each as NAME=value. It returns where rproxy listens and a
|
// settings in env, each as NAME=value. It returns where rproxy listens, the
|
||||||
// function that sends rproxy SIGTERM and fails the test unless it exits with
|
// process, and what rproxy writes.
|
||||||
// status 0 within waitLimit.
|
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
|
||||||
func startRProxyProcess(t *testing.T, env ...string) (string, func()) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
binary, err := os.Executable()
|
binary, err := os.Executable()
|
||||||
@@ -515,34 +517,31 @@ func startRProxyProcess(t *testing.T, env ...string) (string, func()) {
|
|||||||
t.Fatalf("find the test binary: %v", err)
|
t.Fatalf("find the test binary: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
|
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||||
cmd.Stdout = out
|
cmd.Stdout = out
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
commandForMain+"=rproxy",
|
commandForMain+"=rproxy",
|
||||||
"SWW_RPROXY_LISTEN_ADDR="+localhost+":0")
|
"SWRP_LISTEN_ADDR="+localhost+":0")
|
||||||
cmd.Env = append(cmd.Env, env...)
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
|
||||||
exited := startProcess(t, cmd)
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
t.Fatalf("start: %v", err)
|
||||||
|
|
||||||
return addr, func() {
|
|
||||||
err := cmd.Process.Signal(syscall.SIGTERM)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("signal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case status := <-exited:
|
|
||||||
if status != 0 {
|
|
||||||
t.Errorf("exit status %d, want 0; output:\n%s", status, out.text())
|
|
||||||
}
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Error("still running after being told to stop")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
|
return addr, cmd, out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
||||||
@@ -573,9 +572,9 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
|||||||
t.Fatalf("write the certificate: %v", err)
|
t.Fatalf("write the certificate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr, _ := startRProxyProcess(t,
|
addr, _, _ := startRProxyProcess(t,
|
||||||
wafBackend+"="+backend.URL,
|
wafBackend+"="+backend.URL,
|
||||||
"SWW_RPROXY_UPSTREAM_URL="+startApp(t),
|
"SWRP_UPSTREAM_URL="+startApp(t),
|
||||||
"SSL_CERT_FILE="+certificates)
|
"SSL_CERT_FILE="+certificates)
|
||||||
|
|
||||||
for name, headers := range map[string]string{
|
for name, headers := range map[string]string{
|
||||||
@@ -640,10 +639,10 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
rproxyListenAddr: localhost + ":0",
|
rproxyListenAddr: localhost + ":0",
|
||||||
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
"SWRP_UPSTREAM_URL": startApp(t),
|
||||||
rproxyTimeout: "60000",
|
rproxyTimeout: "60000",
|
||||||
wafBackend: backend.URL,
|
wafBackend: backend.URL,
|
||||||
}
|
}
|
||||||
out := &output{}
|
out := &output{}
|
||||||
exited := make(chan int, 1)
|
exited := make(chan int, 1)
|
||||||
@@ -652,7 +651,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
exited <- runRProxy(ctx, env, out)
|
exited <- runRProxy(ctx, env, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
wantStatus(t, "http://"+addr+"/", placed, http.StatusOK)
|
wantStatus(t, "http://"+addr+"/", placed, http.StatusOK)
|
||||||
|
|
||||||
@@ -717,7 +716,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
cutOffAfter = 5 * time.Second
|
cutOffAfter = 5 * time.Second
|
||||||
upstreamSetting = "SWW_RPROXY_UPSTREAM_URL"
|
upstreamSetting = "SWRP_UPSTREAM_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
asked := make(chan struct{})
|
asked := make(chan struct{})
|
||||||
@@ -751,7 +750,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
|||||||
exited <- runRProxy(ctx, env, out)
|
exited <- runRProxy(ctx, env, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -839,14 +838,14 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.line(t, "msg", "cannot listen on SWW_RPROXY_LISTEN_ADDR")
|
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainRunsRProxy(t *testing.T) {
|
func TestMainRunsRProxy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Nothing listens there; the health check does not ask.
|
// Nothing listens there; the health check does not ask.
|
||||||
addr, stop := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
"http://"+addr+waf.HealthPath, http.NoBody)
|
"http://"+addr+waf.HealthPath, http.NoBody)
|
||||||
@@ -869,5 +868,13 @@ func TestMainRunsRProxy(t *testing.T) {
|
|||||||
t.Errorf("health check answered %q (%v)", body, err)
|
t.Errorf("health check answered %q (%v)", body, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stop()
|
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+26
-31
@@ -1,9 +1,9 @@
|
|||||||
// Package sww runs the sww process, as the command it is given.
|
// Package sww runs the sww process, as the command it is given.
|
||||||
// `sww waf` reads the settings, the rule files, the lookup database
|
// `sww waf` reads the settings, the rule files, the lookup database
|
||||||
// and the state and cache files, answers rproxy's questions until it is told to
|
// and the state files, answers rproxy's questions until it is told to stop, and
|
||||||
// stop, and then stops in an orderly way, writing the state and cache files.
|
// then stops in an orderly way, writing the state files. `sww rproxy`
|
||||||
// `sww rproxy` reads rproxy's settings alone, and runs the reverse proxy in
|
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
|
||||||
// front of one app that asks waf about each request. `sww healthcheck` is the
|
// that asks waf about each request. `sww healthcheck` is the
|
||||||
// container's health check.
|
// container's health check.
|
||||||
package sww
|
package sww
|
||||||
|
|
||||||
@@ -20,8 +20,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
@@ -40,7 +38,7 @@ import (
|
|||||||
const shutdownTimeout = 5 * time.Second
|
const shutdownTimeout = 5 * time.Second
|
||||||
|
|
||||||
// remoteLogStopTimeout is how long, as waf stops, the log lines still
|
// remoteLogStopTimeout is how long, as waf stops, the log lines still
|
||||||
// waiting are sent to SWW_WAF_LOG_REMOTE_URL before they are given up. stdout has
|
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
|
||||||
// carried them.
|
// carried them.
|
||||||
const remoteLogStopTimeout = 2 * time.Second
|
const remoteLogStopTimeout = 2 * time.Second
|
||||||
|
|
||||||
@@ -61,9 +59,8 @@ commands:
|
|||||||
type Params struct {
|
type Params struct {
|
||||||
// Version is the version of the binary, set when it is built.
|
// Version is the version of the binary, set when it is built.
|
||||||
Version string
|
Version string
|
||||||
// Viper reads the settings, normally the viper config.NewViper returns
|
// LookupEnv reads an environment variable, normally os.LookupEnv.
|
||||||
// for the process's environment.
|
LookupEnv func(string) (string, bool)
|
||||||
Viper *viper.Viper
|
|
||||||
// Stdout receives the request log and the process's own messages.
|
// Stdout receives the request log and the process's own messages.
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
}
|
}
|
||||||
@@ -82,7 +79,7 @@ func Main(version string) int {
|
|||||||
syscall.SIGTERM, os.Interrupt)
|
syscall.SIGTERM, os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout}
|
params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "rproxy":
|
case "rproxy":
|
||||||
@@ -90,7 +87,7 @@ func Main(version string) int {
|
|||||||
case "waf":
|
case "waf":
|
||||||
return RunWAF(ctx, params)
|
return RunWAF(ctx, params)
|
||||||
case "healthcheck":
|
case "healthcheck":
|
||||||
return HealthCheck(ctx, args[1:], params.Viper, os.Stderr)
|
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
|
||||||
default:
|
default:
|
||||||
return printUsage(os.Stderr)
|
return printUsage(os.Stderr)
|
||||||
}
|
}
|
||||||
@@ -104,22 +101,22 @@ func printUsage(w io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunWAF is `sww waf`: it reads the settings, the rule files,
|
// RunWAF is `sww waf`: it reads the settings, the rule files,
|
||||||
// the lookup database and the state and cache files, then serves requests until
|
// the lookup database and the state files, then serves requests until ctx is
|
||||||
// ctx is done. It returns the process's exit status, 1 when waf cannot start.
|
// done. It returns the process's exit status, 1 when waf cannot start.
|
||||||
func RunWAF(ctx context.Context, params Params) int {
|
func RunWAF(ctx context.Context, params Params) int {
|
||||||
// Until the settings are read, the one message is an invalid setting's
|
// Until the settings are read, the one message is an invalid setting's
|
||||||
// error, which every SWW_WAF_LOG_LEVEL lets through.
|
// error, which every SWW_LOG_LEVEL lets through.
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
config.InstanceName(params.Viper), slog.LevelError)
|
config.InstanceName(params.LookupEnv), slog.LevelError)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.Viper)
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("invalid setting", "error", err.Error())
|
processLog.Error("invalid setting", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// While SWW_WAF_LOG_REMOTE_URL is set, every line on stdout from here on
|
// While SWW_LOG_REMOTE_URL is set, every line on stdout from here on
|
||||||
// is sent there too.
|
// is sent there too.
|
||||||
stdout := params.Stdout
|
stdout := params.Stdout
|
||||||
|
|
||||||
@@ -174,14 +171,14 @@ func RunWAF(ctx context.Context, params Params) int {
|
|||||||
|
|
||||||
files, err := loadStateFiles(cfg, server, alertQueue, now, processLog)
|
files, err := loadStateFiles(cfg, server, alertQueue, now, processLog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot use the state and cache files", "error", err.Error())
|
processLog.Error("cannot use the state files", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot listen on SWW_WAF_LISTEN_ADDR", "error", err.Error())
|
processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -195,7 +192,7 @@ func RunWAF(ctx context.Context, params Params) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newServer returns the server waf 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_WAF_LOOKUP_SOURCE is file. A lookup
|
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
|
||||||
// database that cannot be read is an error.
|
// database that cannot be read is an error.
|
||||||
func newServer(
|
func newServer(
|
||||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||||
@@ -239,15 +236,14 @@ func newServer(
|
|||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadStateFiles reads the state and cache files into the parts of server and
|
// loadStateFiles reads the state files into the parts of server and into
|
||||||
// into alertQueue, as state.Load does.
|
// alertQueue, as state.Load does.
|
||||||
func loadStateFiles(
|
func loadStateFiles(
|
||||||
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
|
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
|
||||||
now func() time.Time, processLog *slog.Logger,
|
now func() time.Time, processLog *slog.Logger,
|
||||||
) (*state.Files, error) {
|
) (*state.Files, error) {
|
||||||
return state.Load(state.Params{
|
return state.Load(state.Params{
|
||||||
Dir: cfg.StateDir,
|
Dir: cfg.StateDir,
|
||||||
CacheDir: cfg.CacheDir,
|
|
||||||
WriteDelay: cfg.StateWriteDelay,
|
WriteDelay: cfg.StateWriteDelay,
|
||||||
CounterInterval: cfg.StateCounterInterval,
|
CounterInterval: cfg.StateCounterInterval,
|
||||||
Ledger: server.Ledger,
|
Ledger: server.Ledger,
|
||||||
@@ -256,7 +252,6 @@ func loadStateFiles(
|
|||||||
Lists: server.Lists,
|
Lists: server.Lists,
|
||||||
DNSBL: server.DNSBL,
|
DNSBL: server.DNSBL,
|
||||||
AbuseIPDB: server.AbuseIPDB,
|
AbuseIPDB: server.AbuseIPDB,
|
||||||
RouteWatch: server.RouteWatch,
|
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
Anomalies: server.Anomalies,
|
Anomalies: server.Anomalies,
|
||||||
Now: now,
|
Now: now,
|
||||||
@@ -286,7 +281,7 @@ func newAlertQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newRemoteLogSender returns a sender of the log lines to
|
// newRemoteLogSender returns a sender of the log lines to
|
||||||
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
// SWW_LOG_REMOTE_URL, with the settings for it.
|
||||||
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
||||||
return remotelog.New(remotelog.Params{
|
return remotelog.New(remotelog.Params{
|
||||||
URL: cfg.LogRemoteURL,
|
URL: cfg.LogRemoteURL,
|
||||||
@@ -322,13 +317,13 @@ func startSending(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve serves requests on listener, writes the state and cache files as they
|
// serve serves requests on listener, writes the state files as they are
|
||||||
// are due, takes in an admin's edits of them, reads the rule files again as
|
// due, takes in an admin's edits of them, reads the rule files again as
|
||||||
// they change, and the lookup database when it is replaced, fetches the
|
// they change, and the lookup database when it is replaced, fetches the
|
||||||
// lists the settings name by URL as they are due, and sends the alerts,
|
// lists the settings name by URL as they are due, and sends the alerts,
|
||||||
// until ctx is done. Then it gives the requests in progress
|
// until ctx is done. Then it gives the requests in progress
|
||||||
// shutdownTimeout to finish, and writes every state and cache file,
|
// shutdownTimeout to finish, and writes every state file, alerts.json with
|
||||||
// alerts.json with the alerts still waiting.
|
// the alerts still waiting.
|
||||||
func serve(
|
func serve(
|
||||||
ctx context.Context, server *waf.Server, listener net.Listener,
|
ctx context.Context, server *waf.Server, listener net.Listener,
|
||||||
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
@@ -398,7 +393,7 @@ func serve(
|
|||||||
|
|
||||||
err = files.WriteAll()
|
err = files.WriteAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("writing the state and cache files failed", "error", err.Error())
|
processLog.Error("writing the state files failed", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-163
@@ -20,7 +20,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
@@ -35,22 +34,21 @@ const (
|
|||||||
testVersion = "test"
|
testVersion = "test"
|
||||||
// localhost is where the tests listen.
|
// localhost is where the tests listen.
|
||||||
localhost = "127.0.0.1"
|
localhost = "127.0.0.1"
|
||||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
stateDir = "SWW_WAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
cacheDir = "SWW_WAF_CACHE_DIR"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set.
|
||||||
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
|
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
|
// instance is the SWW_INSTANCE_NAME the tests set where they look at
|
||||||
// it.
|
// it.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
// greeting is what the tests' app answers.
|
// greeting is what the tests' app answers.
|
||||||
@@ -151,24 +149,20 @@ func (o *output) text() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// run runs `sww waf` 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_WAF_LOOKUP_SOURCE is off unless env sets it,
|
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
|
||||||
// so that no test sends GeoJS its clients' addresses.
|
// so that no test sends GeoJS its clients' addresses.
|
||||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||||
v := configtest.Viper(env)
|
|
||||||
if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set {
|
|
||||||
v.Set("SWW_WAF_LOOKUP_SOURCE", "off")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cache files go beside the state files unless the test says
|
|
||||||
// otherwise.
|
|
||||||
if _, set := env[cacheDir]; !set && env[stateDir] != "" {
|
|
||||||
v.Set(cacheDir, env[stateDir])
|
|
||||||
}
|
|
||||||
|
|
||||||
return sww.RunWAF(ctx, sww.Params{
|
return sww.RunWAF(ctx, sww.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
Viper: v,
|
LookupEnv: func(name string) (string, bool) {
|
||||||
Stdout: out,
|
value, ok := env[name]
|
||||||
|
if !ok && name == "SWW_LOOKUP_SOURCE" {
|
||||||
|
return "off", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
},
|
||||||
|
Stdout: out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +172,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
"SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots",
|
"SWW_BYTES_LIMIT_PER_MINUTE": "lots",
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
@@ -190,7 +184,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
if line["type"] != "process" || line["instance"] != instance ||
|
if line["type"] != "process" || line["instance"] != instance ||
|
||||||
line["level"] != "ERROR" ||
|
line["level"] != "ERROR" ||
|
||||||
!strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") {
|
!strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
|
||||||
t.Errorf("start refused with %v", line)
|
t.Errorf("start refused with %v", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +240,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR")
|
out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServesUntilToldToStop(t *testing.T) {
|
func TestServesUntilToldToStop(t *testing.T) {
|
||||||
@@ -310,11 +304,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
exited <- run(ctx, map[string]string{
|
exited <- run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
||||||
"SWW_WAF_LOG_LEVEL": "warn",
|
"SWW_LOG_LEVEL": "warn",
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -417,7 +411,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
const scope = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
@@ -537,7 +531,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
rulesDir: dir,
|
rulesDir: dir,
|
||||||
// The requests sent until the rule takes effect must not break a
|
// The requests sent until the rule takes effect must not break a
|
||||||
// rate limit, whose ban would refuse them too.
|
// rate limit, whose ban would refuse them too.
|
||||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
|
"SWW_RATE_LIMIT_PER_MINUTE": "off",
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -580,7 +574,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,14 +586,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the Core Rule Set")
|
line := out.line(t, "msg", "cannot use the Core Rule Set")
|
||||||
want := "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
|
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
|
||||||
path + ": no such file or directory"
|
path + ": no such file or directory"
|
||||||
|
|
||||||
if line["error"] != want {
|
if line["error"] != want {
|
||||||
@@ -624,8 +618,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
|
|||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
|
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Fatalf("%s: exit status %d, want 1", path, status)
|
t.Fatalf("%s: exit status %d, want 1", path, status)
|
||||||
@@ -644,14 +637,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
writeLookupDatabase(t, path, "DE")
|
writeLookupDatabase(t, path, "DE")
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWW_WAF_TAG_DENY": "country=KP",
|
"SWW_TAG_DENY": "country=KP",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
@@ -706,7 +699,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -739,15 +732,14 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
|||||||
t.Cleanup(server.Close)
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
torURL, dropURL := server.URL+torPath, server.URL+dropPath
|
torURL, dropURL := server.URL+torPath, server.URL+dropPath
|
||||||
cache := t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: dir,
|
||||||
cacheDir: cache,
|
rulesDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
metricsToken: token,
|
||||||
metricsToken: token,
|
instanceName: instance,
|
||||||
instanceName: instance,
|
"SWW_BLOCKLIST_URLS": torURL,
|
||||||
"SWW_WAF_BLOCKLIST_URLS": torURL,
|
|
||||||
// The requests sent until the list takes effect, and those for the
|
// The requests sent until the list takes effect, and those for the
|
||||||
// metrics, must not break a rate limit, whose ban would refuse them
|
// metrics, must not break a rate limit, whose ban would refuse them
|
||||||
// too.
|
// too.
|
||||||
@@ -763,12 +755,12 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Restarted with drop.txt named after it and the server answering, tor.txt
|
// Restarted with drop.txt named after it and the server answering, tor.txt
|
||||||
// waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in lists.json,
|
// waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
|
||||||
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
||||||
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
||||||
failing.Store(false)
|
failing.Store(false)
|
||||||
|
|
||||||
env["SWW_WAF_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
for statusFrom(t, url, placed) != http.StatusForbidden {
|
for statusFrom(t, url, placed) != http.StatusForbidden {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
@@ -781,8 +773,8 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After another restart, with the server failing, the copy of drop.txt
|
// After another restart, with the server failing, the copy of drop.txt
|
||||||
// kept in lists.json, its copyright line included, refuses the client
|
// kept in reputation.json, its copyright line included, refuses the
|
||||||
// from the first request.
|
// client from the first request.
|
||||||
failing.Store(true)
|
failing.Store(true)
|
||||||
|
|
||||||
out = runUntilStopped(t, env, func(url string) {
|
out = runUntilStopped(t, env, func(url string) {
|
||||||
@@ -790,16 +782,16 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantDeniedByList(t, out.line(t, "type", "request"), dropURL)
|
wantDeniedByList(t, out.line(t, "type", "request"), dropURL)
|
||||||
|
|
||||||
path := filepath.Join(cache, "lists.json")
|
path := filepath.Join(dir, "reputation.json")
|
||||||
|
|
||||||
kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
|
kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
|
||||||
if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) {
|
if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) {
|
||||||
t.Errorf("lists.json holds\n%s\nwant the copy with %q (%v)", kept, copyright,
|
t.Errorf("reputation.json holds\n%s\nwant the copy with %q (%v)", kept, copyright,
|
||||||
err)
|
err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const bouncerKey = "crowdsec-key-0123456789abcdef"
|
const bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -822,14 +814,13 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(engine.Close)
|
t.Cleanup(engine.Close)
|
||||||
|
|
||||||
dir, cache := t.TempDir(), t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
cacheDir: cache,
|
rulesDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
"SWW_CROWDSEC_LAPI_URL": engine.URL,
|
||||||
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
|
"SWW_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||||
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the list is fetched, the client's request bans it.
|
// Once the list is fetched, the client's request bans it.
|
||||||
@@ -845,7 +836,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
|||||||
t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed)
|
t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restarted with the engine down, the copy kept in lists.json bans
|
// Restarted with the engine down, the copy kept in reputation.json bans
|
||||||
// another client in the netblock from its first request.
|
// another client in the netblock from its first request.
|
||||||
down.Store(true)
|
down.Store(true)
|
||||||
|
|
||||||
@@ -853,25 +844,22 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
|||||||
wantStatus(t, url, "203.0.113.10", http.StatusForbidden)
|
wantStatus(t, url, "203.0.113.10", http.StatusForbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
// The key is in neither run's output, nor in a state or cache file.
|
// The key is in neither run's output, nor in a state file.
|
||||||
files := []string{
|
files := []string{"bans.json", "reputation.json", "clients.json"}
|
||||||
filepath.Join(dir, "bans.json"), filepath.Join(dir, "clients.json"),
|
|
||||||
filepath.Join(cache, "lists.json"),
|
|
||||||
}
|
|
||||||
shown := make([]string, 0, len(files)+2)
|
shown := make([]string, 0, len(files)+2)
|
||||||
shown = append(shown, first.text(), second.text())
|
shown = append(shown, first.text(), second.text())
|
||||||
|
|
||||||
for _, path := range files {
|
for _, name := range files {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the test's
|
data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // the test's
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", path, err)
|
t.Fatalf("read %s: %v", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shown = append(shown, string(data))
|
shown = append(shown, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) {
|
if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) {
|
||||||
t.Errorf("the key is shown in the output or the state and cache files:\n%s", all)
|
t.Errorf("the key is shown in the output or the state files:\n%s", all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,7 +894,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + path +
|
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
|
||||||
": no such file or directory"
|
": no such file or directory"
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the lookup database")
|
line := out.line(t, "msg", "cannot use the lookup database")
|
||||||
@@ -928,10 +916,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -983,13 +971,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
||||||
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
|
"SWW_LOG_REMOTE_BUFFER": "1",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1053,12 +1041,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: rules,
|
rulesDir: rules,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
|
"SWW_ALERT_WEBHOOK_URL": webhook.url,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1106,7 +1094,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
|
|
||||||
webhook.failing.Store(false)
|
webhook.failing.Store(false)
|
||||||
|
|
||||||
env["SWW_WAF_ALLOW_NETS"] = localhost
|
env["SWW_ALLOW_NETS"] = localhost
|
||||||
env[metricsToken] = token
|
env[metricsToken] = token
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1146,12 +1134,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||||
"SWW_WAF_ALLOW_NETS": localhost + "/32",
|
"SWW_ALLOW_NETS": localhost + "/32",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
||||||
"SWW_WAF_ALERT_NTFY_URL": ntfy.url,
|
"SWW_ALERT_NTFY_URL": ntfy.url,
|
||||||
"SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken,
|
"SWW_ALERT_NTFY_TOKEN": ntfyToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1216,30 +1204,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
||||||
"SWW_WAF_STATE_DIR cannot be written: ")
|
"SWW_STATE_DIR cannot be written: ")
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, stop := context.WithTimeout(t.Context(), waitLimit)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
out := &output{}
|
|
||||||
|
|
||||||
status := run(ctx, map[string]string{
|
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
|
||||||
cacheDir: filepath.Join(t.TempDir(), "missing"),
|
|
||||||
}, out)
|
|
||||||
if status != 1 {
|
|
||||||
t.Fatalf("exit status %d, want 1", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the state and cache files")
|
|
||||||
if message, _ := line["error"].(string); !strings.HasPrefix(message,
|
|
||||||
"SWW_WAF_CACHE_DIR cannot be written: ") {
|
|
||||||
t.Errorf("start refused with %q, want SWW_WAF_CACHE_DIR named", message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantStartRefused runs sww with its state files in dir, and
|
// wantStartRefused runs sww with its state files in dir, and
|
||||||
@@ -1260,7 +1225,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
|||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the state and cache files")
|
line := out.line(t, "msg", "cannot use the state files")
|
||||||
message, _ := line["error"].(string)
|
message, _ := line["error"].(string)
|
||||||
|
|
||||||
if !strings.HasPrefix(message, want) {
|
if !strings.HasPrefix(message, want) {
|
||||||
@@ -1345,28 +1310,28 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
|
|
||||||
settings, _ := line["settings"].(map[string]any)
|
settings, _ := line["settings"].(map[string]any)
|
||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
"SWW_WAF_MODE": "enforce",
|
"SWW_MODE": "enforce",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
"SWW_WAF_ALLOW_NETS": "",
|
"SWW_ALLOW_NETS": "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
"SWW_WAF_DENY_NETS": "",
|
"SWW_DENY_NETS": "",
|
||||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "120",
|
"SWW_RATE_LIMIT_PER_MINUTE": "120",
|
||||||
"SWW_WAF_RATE_LIMIT_PER_HOUR": "1500",
|
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
"SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWW_WAF_TAG_DENY": "",
|
"SWW_TAG_DENY": "",
|
||||||
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
"SWW_WAF_BAN_RESPONSE": "403",
|
"SWW_BAN_RESPONSE": "403",
|
||||||
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
|
"SWW_LIMIT_BAN_DURATION": "1h",
|
||||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||||
"SWW_WAF_MAX_BAN_DURATION": "7d",
|
"SWW_MAX_BAN_DURATION": "7d",
|
||||||
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
|
"SWW_ATTACK_BAN_DURATION": "7d",
|
||||||
"SWW_WAF_MAX_BANS": "5000",
|
"SWW_MAX_BANS": "5000",
|
||||||
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
|
"SWW_BAN_SCOPE_V4_PREFIX": "32",
|
||||||
"SWW_WAF_RULES_ENABLED": "true",
|
"SWW_RULES_ENABLED": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
@@ -1566,7 +1531,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantRefused checks that waf at url refuses a request from localhost with
|
// wantRefused checks that waf at url refuses a request from localhost with
|
||||||
// 403, the default SWW_WAF_BAN_RESPONSE.
|
// 403, the default SWW_BAN_RESPONSE.
|
||||||
func wantRefused(t *testing.T, url string) {
|
func wantRefused(t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1607,8 +1572,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
|
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
||||||
// 200.
|
// 200.
|
||||||
type destination struct {
|
type destination struct {
|
||||||
url string
|
url string
|
||||||
@@ -1667,7 +1632,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhook is a stand-in for SWW_WAF_ALERT_WEBHOOK_URL. It notes each alert
|
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
|
||||||
// it is sent, and answers 204, or 503 while failing.
|
// it is sent, and answers 204, or 503 while failing.
|
||||||
type webhook struct {
|
type webhook struct {
|
||||||
url string
|
url string
|
||||||
|
|||||||
+18
-41
@@ -38,35 +38,6 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(sww.Main(testVersion))
|
os.Exit(sww.Main(testVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProcess starts cmd and returns a channel that gets its exit status. As
|
|
||||||
// the test ends, cmd is killed and the test waits for it to exit.
|
|
||||||
func startProcess(t *testing.T, cmd *exec.Cmd) <-chan int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := cmd.Start()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exited := make(chan int, 1)
|
|
||||||
waitReturned := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = cmd.Wait()
|
|
||||||
exited <- cmd.ProcessState.ExitCode()
|
|
||||||
|
|
||||||
close(waitReturned)
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
|
|
||||||
<-waitReturned
|
|
||||||
})
|
|
||||||
|
|
||||||
return exited
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainRunsWAF(t *testing.T) {
|
func TestMainRunsWAF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -83,9 +54,12 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
t.Fatalf("find the test binary: %v", err)
|
t.Fatalf("find the test binary: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
out := &output{}
|
out := &output{}
|
||||||
cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
|
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||||
cmd.Stdout = out
|
cmd.Stdout = out
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
@@ -93,13 +67,20 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
commandForMain+"="+wafCommand,
|
commandForMain+"="+wafCommand,
|
||||||
listenAddr+"="+localhost+":0",
|
listenAddr+"="+localhost+":0",
|
||||||
stateDir+"="+dir,
|
stateDir+"="+dir,
|
||||||
cacheDir+"="+dir,
|
|
||||||
rulesDir+"="+t.TempDir(),
|
rulesDir+"="+t.TempDir(),
|
||||||
lookupSource+"=off")
|
lookupSource+"=off")
|
||||||
|
|
||||||
exited := startProcess(t, cmd)
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
starting := out.lineBeforeExit(t, "starting", exited)
|
t.Cleanup(func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
starting := out.line(t, "msg", "starting")
|
||||||
addr, _ := starting["address"].(string)
|
addr, _ := starting["address"].(string)
|
||||||
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
|
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
|
||||||
|
|
||||||
@@ -109,13 +90,9 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
t.Fatalf("signal: %v", err)
|
t.Fatalf("signal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
err = cmd.Wait()
|
||||||
case status := <-exited:
|
if err != nil {
|
||||||
if status != 0 {
|
t.Fatalf("exit: %v; output:\n%s", err, out.text())
|
||||||
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
|
|
||||||
}
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Fatal("still running after being told to stop")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wantUTC(t, "the process line", starting["time"])
|
wantUTC(t, "the process line", starting["time"])
|
||||||
@@ -238,7 +215,7 @@ func runWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
|||||||
exited <- run(ctx, env, out)
|
exited <- run(ctx, env, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
use(addr)
|
use(addr)
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
|
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||||
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
|
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -16,12 +16,12 @@ import (
|
|||||||
|
|
||||||
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
||||||
// the score, when its score of the client is a hit, and refuses the request
|
// the score, when its score of the client is a hit, and refuses the request
|
||||||
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
||||||
// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
||||||
// checked in the background, by the request's address, if its history counts
|
// checked in the background, by the request's address, if its history counts
|
||||||
// an offence, and the request does not wait for the answer. The score is then
|
// an offence, and the request does not wait for the answer. The score is then
|
||||||
// used for each address of the client. Its figures are the score and
|
// used for each address of the client. Its figures are the score and
|
||||||
// SWW_WAF_ABUSEIPDB_MIN_SCORE.
|
// SWW_ABUSEIPDB_MIN_SCORE.
|
||||||
type abuseIPDB struct{}
|
type abuseIPDB struct{}
|
||||||
|
|
||||||
func (abuseIPDB) name() string { return "abuseipdb" }
|
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||||
@@ -43,7 +43,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
rq.abuseIPDBHit = true
|
rq.abuseIPDBHit = true
|
||||||
rq.addTag(config.AbuseIPDBTag)
|
rq.addTag(config.AbuseIPDBTag)
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE")
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
if rq.h.config.ReputationAction != deny {
|
if rq.h.config.ReputationAction != deny {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (rq *request) answerHealth() {
|
|||||||
|
|
||||||
// answerAdmin answers a request for one of waf'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
|
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWW_WAF_METRICS_TOKEN, the others SWW_WAF_ADMIN_TOKEN. A request without it is
|
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||||
// path does.
|
// path does.
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
@@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWW_WAF_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/blog"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWW_WAF_WATCH_"
|
anomalyWatch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWW_WAF_WATCH_NETS"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWW_WAF_DENY_NETS
|
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
|||||||
+40
-115
@@ -1,7 +1,6 @@
|
|||||||
package waf_test
|
package waf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,13 +11,10 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
@@ -29,13 +25,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||||
// waf has to answer.
|
// waf has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
|
|
||||||
defaultReputationWait = 2 * time.Second
|
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
headerFieldLimit = 100
|
headerFieldLimit = 100
|
||||||
@@ -181,7 +175,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{wafMode: off},
|
||||||
{wafMode: "detect"},
|
{wafMode: "detect"},
|
||||||
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -254,7 +248,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
|||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *waf.Server, now time.Time)
|
prepare func(t *testing.T, server *waf.Server, now time.Time)
|
||||||
}{
|
}{
|
||||||
{"SWW_WAF_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
|
{"SWW_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
|
||||||
{"a ban", nil, "/", banClient},
|
{"a ban", nil, "/", banClient},
|
||||||
{
|
{
|
||||||
"the country list", map[string]string{
|
"the country list", map[string]string{
|
||||||
@@ -279,12 +273,12 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
|||||||
"AbuseIPDB",
|
"AbuseIPDB",
|
||||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||||
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||||
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// Over SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadScores(server, map[string]int64{client: 80})
|
loadScores(server, map[string]int64{client: 80})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"SWW_WAF_TAG_DENY", map[string]string{tagLists: "vpn=" + vpnList, tagDeny: "vpn"},
|
"SWW_TAG_DENY", map[string]string{tagLists: "vpn=" + vpnList, tagDeny: "vpn"},
|
||||||
"/", func(t *testing.T, server *waf.Server, _ time.Time) {
|
"/", func(t *testing.T, server *waf.Server, _ time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
loadLists(t, server, map[string][]string{vpnList: {client}})
|
loadLists(t, server, map[string][]string{vpnList: {client}})
|
||||||
@@ -516,7 +510,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWW_WAF_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -634,9 +628,15 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// The lists' stand-in: one for GeoJS that answers nothing.
|
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||||
held, _, _ := startHeldGeoJS(t)
|
|
||||||
resolver, releaseResolver := startHeldResolver(t)
|
// A resolver that takes every query and answers none.
|
||||||
|
resolver, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = resolver.Close() })
|
||||||
|
|
||||||
server, out, _ := newWAFWith(t, func(
|
server, out, _ := newWAFWith(t, func(
|
||||||
params waf.Params,
|
params waf.Params,
|
||||||
@@ -644,117 +644,42 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||||
|
|
||||||
return waf.New(params)
|
return waf.New(params)
|
||||||
}, silentGeoJSURL, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
tagDeny: "country=KP", wafMode: block,
|
tagDeny: "country=KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver,
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
crowdSecURL: held, crowdSecKey: bouncerKey,
|
crowdSecURL: held, crowdSecKey: bouncerKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The lists are fetched outside the synctest bubble below: a wait on the
|
// AbuseIPDB is asked only about a client with an offence.
|
||||||
// network inside it would keep its clock from moving on.
|
group := netip.MustParsePrefix(client + "/32")
|
||||||
|
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
|
||||||
|
|
||||||
go server.Lists.Run(t.Context())
|
go server.Lists.Run(t.Context())
|
||||||
|
|
||||||
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
|
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||||
// answer takes exactly as long as waf waits, however busy the host is.
|
req.Header.Set("User-Agent", "test")
|
||||||
// GeoJS and AbuseIPDB never answer there, without the network. The query to
|
req.Header.Set("Accept", "*/*")
|
||||||
// the DNS blocklist waits on the network, which stops the bubble's clock,
|
req.Host = siteA
|
||||||
// but it starts only after the wait for GeoJS.
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
// AbuseIPDB is asked only about a client with an offence.
|
|
||||||
group := netip.MustParsePrefix(client + "/32")
|
|
||||||
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
|
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
|
||||||
req.Header.Set("User-Agent", "test")
|
|
||||||
req.Header.Set("Accept", "*/*")
|
|
||||||
req.Host = siteA
|
|
||||||
})
|
|
||||||
|
|
||||||
// The wait for GeoJS decides when the answer comes.
|
|
||||||
if status != http.StatusOK || took != defaultLookupWait || took >= answerTimeLimit {
|
|
||||||
t.Errorf("answered %d after %s, want %d after %s, within %s; output:\n%s",
|
|
||||||
status, took, http.StatusOK, defaultLookupWait, answerTimeLimit, out.text())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each outside source was asked, and none has answered. GeoJS is asked
|
|
||||||
// only about the clients waiting for it, here client alone.
|
|
||||||
if requests := testutil.ToFloat64(server.Metrics.GeoJSRequests); requests != 1 {
|
|
||||||
t.Errorf("%v requests to GeoJS, want 1", requests)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The resolver holds the query until it is released below, so a waf
|
|
||||||
// that waited for the query would answer only once it had failed.
|
|
||||||
queries, failures := server.DNSBL.Queries(dnsblZone), server.DNSBL.Failures(dnsblZone)
|
|
||||||
if queries != 1 || failures != 0 {
|
|
||||||
t.Errorf("%d queries to the DNS blocklist, %d failed, want 1, none failed",
|
|
||||||
queries, failures)
|
|
||||||
}
|
|
||||||
|
|
||||||
if checked := server.AbuseIPDB.Checked(); checked != 1 {
|
|
||||||
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The query is answered, and the check with AbuseIPDB abandoned,
|
|
||||||
// before the bubble ends.
|
|
||||||
releaseResolver()
|
|
||||||
time.Sleep(defaultReputationWait)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// How the stand-in for the resolver reads a query and makes it its reply.
|
if status != http.StatusOK || took >= answerTimeLimit {
|
||||||
const (
|
t.Errorf("answered %d after %s, want %d within %s; output:\n%s",
|
||||||
// maxQueryLength is the longest query it reads.
|
status, took, http.StatusOK, answerTimeLimit, out.text())
|
||||||
maxQueryLength = 512
|
|
||||||
// flagsAt is where a DNS message's flags start, after its id.
|
|
||||||
flagsAt = 2
|
|
||||||
// noSuchNameFlags mark a reply, to a query that asked for recursion,
|
|
||||||
// which is available, that no such name exists.
|
|
||||||
noSuchNameFlags = 0x8183
|
|
||||||
)
|
|
||||||
|
|
||||||
// startHeldResolver starts a stand-in for the resolver the DNSBL zones are
|
|
||||||
// asked through, and returns its address and release. Each query waits until
|
|
||||||
// release is called, and is then answered that no such name exists, so that
|
|
||||||
// the zone does not list the client.
|
|
||||||
func startHeldResolver(t *testing.T) (string, func()) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() { _ = conn.Close() })
|
// Each outside source was asked, and none has answered. The stand-in for
|
||||||
|
// GeoJS is the lists' too, which name no address.
|
||||||
|
if !slices.Contains(askedGeoJS(), client) {
|
||||||
|
t.Errorf("GeoJS was asked about %q, not %s", askedGeoJS(), client)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
||||||
released = make(chan struct{})
|
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
|
||||||
once sync.Once
|
}
|
||||||
)
|
|
||||||
|
|
||||||
go func() {
|
if checked := server.AbuseIPDB.Checked(); checked != 1 {
|
||||||
query := make([]byte, maxQueryLength)
|
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
|
||||||
|
}
|
||||||
for {
|
|
||||||
n, from, err := conn.ReadFrom(query)
|
|
||||||
if err != nil {
|
|
||||||
return // conn is closed
|
|
||||||
}
|
|
||||||
|
|
||||||
<-released
|
|
||||||
|
|
||||||
// The query back, with the flags of the reply in place of its own.
|
|
||||||
reply := slices.Clone(query[:n])
|
|
||||||
binary.BigEndian.PutUint16(reply[flagsAt:], noSuchNameFlags)
|
|
||||||
|
|
||||||
_, _ = conn.WriteTo(reply, from)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
release := func() { once.Do(func() { close(released) }) }
|
|
||||||
// Run before conn is closed, so that no query is left waiting.
|
|
||||||
t.Cleanup(release)
|
|
||||||
|
|
||||||
return conn.LocalAddr().String(), release
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic active_ban, and the code that makes bans, which several
|
// The heuristic active_ban, and the code that makes bans, which several
|
||||||
// heuristics share. active_ban reads SWW_WAF_MODE; the bans are the ban ledger's.
|
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWW_WAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
@@ -77,7 +77,7 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
|
|||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -237,7 +237,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
|||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWW_WAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -303,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWW_WAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
|
|||||||
+11
-11
@@ -26,7 +26,7 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
// banResponseClose is the SWW_WAF_BAN_RESPONSE that closes the connection.
|
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||||
banResponseClose = "close"
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWW_WAF_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||||
// SWW_WAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWW_WAF_LIMIT_BAN_DURATION again.
|
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -150,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWW_WAF_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -166,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWW_WAF_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -175,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWW_WAF_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -249,10 +249,10 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
|||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWW_WAF_BAN_RESPONSE at its default
|
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
|
|||||||
+17
-18
@@ -17,9 +17,9 @@ type percentage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
|
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
||||||
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
|
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
||||||
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||||
// then needed before its request goes on.
|
// then needed before its request goes on.
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
|||||||
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
||||||
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
||||||
// it, the first of them in the order below when several are lowest: the
|
// it, the first of them in the order below when several are lowest: the
|
||||||
// percentage SWW_WAF_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||||
// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||||
// other tags, for a client without a country, unless its request skipped
|
// other tags, for a client without a country, unless its request skipped
|
||||||
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||||
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
|
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
||||||
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||||
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
|
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
||||||
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
|
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
||||||
// take the place of the first three for an AS number or a country they
|
// take the place of the first three for an AS number or a country they
|
||||||
// list.
|
// list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
@@ -48,25 +48,25 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
|
|
||||||
unknown := percentage{percent: whole}
|
unknown := percentage{percent: whole}
|
||||||
if country == "" && !rq.skippedLookupWait {
|
if country == "" && !rq.skippedLookupWait {
|
||||||
unknown = percentage{cfg.UnknownLimitPercent, "SWW_WAF_UNKNOWN_LIMIT_PERCENT"}
|
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched := percentage{percent: whole}
|
fetched := percentage{percent: whole}
|
||||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
fetched = percentage{percent, "SWW_WAF_ASN_LIMIT_PERCENT_URL"}
|
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||||
}
|
}
|
||||||
|
|
||||||
blocklisted := percentage{percent: whole}
|
blocklisted := percentage{percent: whole}
|
||||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_WAF_BLOCKLIST_ACTION"}
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
reputationListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_WAF_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagSetting = "SWW_WAF_TAG_LIMIT_PERCENT"
|
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
||||||
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
||||||
@@ -81,12 +81,11 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_WAF_ASN_BYTES_PERCENT")
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
countryBytes = given(cfg.CountryBytesPercent, country,
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
"SWW_WAF_COUNTRY_BYTES_PERCENT")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import (
|
|||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -34,7 +34,7 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
// The same, as SWW_WAF_TAG_LIMIT_PERCENT names fromDE's tags.
|
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||||
asnTagDEHalf = "asn=64496:50"
|
asnTagDEHalf = "asn=64496:50"
|
||||||
asnTagDEQuarter = "asn=64496:25"
|
asnTagDEQuarter = "asn=64496:25"
|
||||||
countryTagDEHalf = "country=DE:50"
|
countryTagDEHalf = "country=DE:50"
|
||||||
@@ -240,7 +240,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||||
noCountry, "25 from " + tagLimitPercent,
|
noCountry, "25 from " + tagLimitPercent,
|
||||||
@@ -505,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWW_WAF_TAG_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic blocklists. It reads SWW_WAF_BLOCKLIST_ACTION, and the blocklists
|
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
||||||
// through the reputation lists.
|
// through the reputation lists.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// blocklists notes the blocklists that list the client, as noteListed does,
|
// blocklists notes the blocklists that list the client, as noteListed does,
|
||||||
// and refuses the request when SWW_WAF_BLOCKLIST_ACTION is deny. Being limit, it
|
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
|
||||||
// lowers the client's limits instead (see limitPercentages), and being log, it
|
// lowers the client's limits instead (see limitPercentages), and being log, it
|
||||||
// does nothing more. Its figure is the blocklists that list the client.
|
// does nothing more. Its figure is the blocklists that list the client.
|
||||||
type blocklists struct{}
|
type blocklists struct{}
|
||||||
@@ -35,7 +35,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
|
|
||||||
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
||||||
// client, given listedBy, the blocklists that list it: it does when one lists
|
// client, given listedBy, the blocklists that list it: it does when one lists
|
||||||
// it and SWW_WAF_BLOCKLIST_ACTION is deny.
|
// it and SWW_BLOCKLIST_ACTION is deny.
|
||||||
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
||||||
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
|
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
|
||||||
// and biased thresholds through the limiter and limitPercentages.
|
// and biased thresholds through the limiter and limitPercentages.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWW_WAF_BYTES_COUNT.
|
// The values of SWW_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWW_WAF_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
|||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||||
// length SWW_WAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
|
|||||||
+15
-16
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
|
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||||
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// coreRuleSet inspects the request with the Core Rule Set, unless
|
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
// exempts its path, as pathExempt decides, and notes the rules it matched and
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
// its score in the log line, and the rules in the metrics. A score at or over
|
// log line, and the rules in the metrics. A score at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
||||||
// exploitsignature, raises the core_rule_set_match alert, and in block mode
|
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
|
||||||
// refuses the request with 403, which is an offence its client's history
|
// which is an offence its client's history counts. Its figures are the score
|
||||||
// counts. Its figures are the score and the threshold.
|
// and the threshold.
|
||||||
type coreRuleSet struct{}
|
type coreRuleSet struct{}
|
||||||
|
|
||||||
func (coreRuleSet) name() string { return "core_rule_set" }
|
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||||
@@ -67,10 +67,10 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
||||||
// which the Core Rule Set scored at result, at or over
|
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
|
// detail gives the rule ids, the score, the method and the path with the
|
||||||
// score, the method and the path with the query, and, for a request that is
|
// query, and, for a request that is not refused for it, the mode: detect,
|
||||||
// not refused for it, the mode: detect, or observe in observe mode.
|
// or observe in observe mode.
|
||||||
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||||
detail := map[string]any{
|
detail := map[string]any{
|
||||||
"rule_ids": result.RuleIDs,
|
"rule_ids": result.RuleIDs,
|
||||||
@@ -94,8 +94,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
|||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
Detail: detail,
|
||||||
Detail: detail,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||||
// setting.
|
// setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||||
@@ -177,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -186,7 +186,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantCoreRuleSet(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
@@ -205,7 +205,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionBlockedByBlockRule)
|
||||||
wantCoreRuleSet(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantCoreRuleSet(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
@@ -522,9 +522,8 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
|||||||
Host: appHost,
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
Detail: detail,
|
||||||
Detail: detail,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if queue.Suppressed() != 1 {
|
if queue.Suppressed() != 1 {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic countries. It reads SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countries refuses a request when SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
||||||
// does not hold the client's country as it was looked up. A client without a
|
// does not hold the client's country as it was looked up. A client without a
|
||||||
// country, or whose country cannot be found, is refused. Its figures are the
|
// country, or whose country cannot be found, is refused. Its figures are the
|
||||||
// country and the setting that refused it.
|
// country and the setting that refused it.
|
||||||
@@ -29,7 +29,7 @@ func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
return &verdict{
|
return &verdict{
|
||||||
action: requestlog.ActionCountryDenied,
|
action: requestlog.ActionCountryDenied,
|
||||||
figures: map[string]any{
|
figures: map[string]any{
|
||||||
"country": country, "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ func TestCountryLists(t *testing.T) {
|
|||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"with a country in SWW_WAF_TAG_DENY",
|
"with a country in SWW_TAG_DENY",
|
||||||
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
@@ -100,11 +100,11 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
|||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
wantHeuristic(t, line, "countries",
|
wantHeuristic(t, line, "countries",
|
||||||
`{"country": "KP", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
|
||||||
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
wantHeuristic(t, line, "countries",
|
wantHeuristic(t, line, "countries",
|
||||||
`{"country": "", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||||
@@ -262,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWW_WAF_ALLOW_NETS", "",
|
"not in SWW_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWW_WAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -296,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// leave the client unknown.
|
// leave the client unknown.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic crowdsec. It reads SWW_WAF_CROWDSEC_LAPI_URL, and the decisions
|
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
|
||||||
// through the reputation lists.
|
// through the reputation lists.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import (
|
|||||||
|
|
||||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as lists.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -182,7 +182,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
|
|
||||||
// loadDecisions puts into server's lists the copy of the decision list at
|
// loadDecisions puts into server's lists the copy of the decision list at
|
||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||||
// lists.json would at start.
|
// reputation.json would at start.
|
||||||
func loadDecisions(
|
func loadDecisions(
|
||||||
t *testing.T, server *waf.Server, fetched time.Time, answer string,
|
t *testing.T, server *waf.Server, fetched time.Time, answer string,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic deny_nets. It reads SWW_WAF_DENY_NETS.
|
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// denyNets refuses a client in SWW_WAF_DENY_NETS. Its figure is the netblock of
|
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
|
||||||
// the setting the client is in.
|
// the setting the client is in.
|
||||||
type denyNets struct{}
|
type denyNets struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
|
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
||||||
// verdicts through the DNSBL checker.
|
// verdicts through the DNSBL checker.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||||
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||||
// the request when SWW_WAF_REPUTATION_ACTION is deny. Being
|
// the request when SWW_REPUTATION_ACTION is deny. Being
|
||||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||||
// being log, it does nothing more. A zone without a verdict on the client is
|
// being log, it does nothing more. A zone without a verdict on the client is
|
||||||
// asked about it in the background, and the request does not wait for the
|
// asked about it in the background, and the request does not wait for the
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The lists of heuristics, each in the order its heuristics run. A client in
|
// The lists of heuristics, each in the order its heuristics run. A client in
|
||||||
// SWW_WAF_ALLOW_NETS skips them all.
|
// SWW_ALLOW_NETS skips them all.
|
||||||
|
|
||||||
// beforeLookup are the heuristics that run before the client's AS number and
|
// beforeLookup are the heuristics that run before the client's AS number and
|
||||||
// country are looked up, so that a client they refuse is not looked up.
|
// country are looked up, so that a client they refuse is not looked up.
|
||||||
|
|||||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user