Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8582d7c5ed | ||
|
|
6ec52e5b87 | ||
|
|
cff385af41 | ||
|
|
234c5eac60 | ||
|
|
68f687cb0c | ||
|
|
df2c5042d2 | ||
|
|
73ca94f850 | ||
|
|
0f85c9ae07 | ||
|
|
50df9ee36e |
+3
-3
@@ -58,9 +58,6 @@
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# The binary `make build` writes on the host; the image builds its own.
|
||||
/bin
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
@@ -73,3 +70,6 @@
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# The binary `make build` writes on the host; the image builds its own.
|
||||
/bin
|
||||
|
||||
@@ -162,6 +162,11 @@ RUN groupadd --system --gid 65532 smallwebwaf \
|
||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
||||
smallwebwaf
|
||||
|
||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
||||
# is mounted to keep them across deploys. The run script gives it to the
|
||||
# smallwebwaf user at each start.
|
||||
RUN mkdir /var/lib/smallwebwaf
|
||||
|
||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||
# looks too.
|
||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||
|
||||
@@ -13,15 +13,24 @@ JSON log line for every request.
|
||||
|
||||
Status: the first two milestones are built
|
||||
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
|
||||
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are the static lists,
|
||||
which come next in the build order. `smallwebwaf` passes each request to the app
|
||||
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are seven parts of
|
||||
milestone 3: the static lists, the bans that broken rate limits lead to and the
|
||||
JSON state files with your edits taken in while it runs, which come next in the
|
||||
build order, `observe` mode and the rest of the request log's fields, which come
|
||||
a little later, and the metrics endpoint and the header size and the idle time
|
||||
as settings, which come last in it. `smallwebwaf` passes each request to the app
|
||||
and the app's answer back, unchanged, within its timeouts and size limits, works
|
||||
out each client's address, refuses a client that sends too many requests, comes
|
||||
from a country you refuse or from a network you refuse, lets the networks you
|
||||
choose through, and writes a JSON log line for every request. It comes as the
|
||||
image the app's own image is built on. The rest of the design comes after that,
|
||||
in the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing
|
||||
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
|
||||
out each client's address, bans a client that sends too many requests, refuses a
|
||||
client that comes from a country you refuse or from a network you refuse, lets
|
||||
the networks you choose through, keeps its bans, each client's counters and
|
||||
history, and GeoJS's answers in JSON files across restarts, takes in your edits
|
||||
of those files while it runs, writes a JSON log line for every request, serves
|
||||
Prometheus metrics to a scraper that holds the metrics token, and in `observe`
|
||||
mode passes on the requests it would refuse, logging what it would have done
|
||||
with them. It comes as the image the app's own image is built on. The rest of
|
||||
the design comes after that, in the order of the build order in
|
||||
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
|
||||
[`EVALUATION.md`](EVALUATION.md).
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -43,7 +52,8 @@ works.
|
||||
|
||||
To work on the code, `make build` builds the binary alone, with Go installed,
|
||||
and `make run` builds and runs it, listening on port 8080 in front of an app at
|
||||
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`.
|
||||
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files
|
||||
in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
||||
|
||||
## What it does so far
|
||||
|
||||
@@ -57,45 +67,85 @@ and `make run` builds and runs it, listening on port 8080 in front of an app at
|
||||
address outside `SWWAF_TRUSTED_PROXIES` is the client; if every address in it
|
||||
is inside, the leftmost is, and with no header the peer is. The app sees what
|
||||
it would see from traefik directly: the same `Host`, the same
|
||||
`X-Forwarded-Proto`, and `X-Forwarded-For` with the peer added at the end.
|
||||
- Enforces the four timeouts and the two size limits below. A limit passed
|
||||
before the response has started gets `smallwebwaf`'s own answer: `408` for a
|
||||
client too slow to send its request, `413` for a request body that is too
|
||||
large, `504` for an app too slow to answer, and `502` for a response that is
|
||||
too large or an app that cannot be reached. A request that announces a body
|
||||
over the limit is refused before anything reaches the app. While a request
|
||||
body is still on its way, a request timeout that runs out answers `408` if
|
||||
`smallwebwaf` was waiting for the client to send more, and `504` if it was
|
||||
waiting for the app to take what it had. Once the response has started, a
|
||||
limit can only cut the connection.
|
||||
`X-Forwarded-Proto`, and `X-Forwarded-For` with the peer added at the end. It
|
||||
also gets the request's id in `X-Request-ID`, the same id as in the request's
|
||||
log line (see `request_id` in "Request log" below).
|
||||
- Enforces the timeouts and the size limits below. A limit passed before the
|
||||
response has started gets `smallwebwaf`'s own answer: `408` for a client too
|
||||
slow to send its request, `413` for a request body that is too large, `504`
|
||||
for an app too slow to answer, and `502` for a response that is too large or
|
||||
an app that cannot be reached. A request that announces a body over the limit
|
||||
is refused before anything reaches the app. While a request body is still on
|
||||
its way, a request timeout that runs out answers `408` if `smallwebwaf` was
|
||||
waiting for the client to send more, and `504` if it was waiting for the app
|
||||
to take what it had. Once the response has started, a limit can only cut the
|
||||
connection.
|
||||
- Counts each client's requests over a minute, an hour and a day. A request that
|
||||
takes the client over one of the rate limits below is refused with `429`
|
||||
before anything reaches the app, and so is each request after it until the
|
||||
client is back under every limit. A client is one IPv4 address, or one IPv6
|
||||
/64, since one abuser usually holds a whole /64. Refused requests count too,
|
||||
so a client that keeps sending too fast stays refused until it slows down.
|
||||
Each window is counted in two fixed buckets, the earlier one weighted by how
|
||||
much of it the window still covers. At most 20,000 clients are kept, the least
|
||||
recently seen dropped first, and only in memory: a restart starts every client
|
||||
afresh.
|
||||
- Refuses a request from a country you refuse with `403`, as soon as the
|
||||
client's country is known and before its body is read; such a request is not
|
||||
counted for the rate limits. While one of the country lists below is set, each
|
||||
client's country is looked up through GeoJS (see "Country and AS number
|
||||
lookup" below); with neither set, no visitor's address leaves the host. A
|
||||
client on a private, loopback or link-local address has no country and is
|
||||
takes the client over one of the rate limits below is refused with
|
||||
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
||||
bans the client. A client is one IPv4 address, or one IPv6 /64, since one
|
||||
abuser usually holds a whole /64. Each window is counted in two fixed buckets,
|
||||
the earlier one weighted by how much of it the window still covers. At most
|
||||
20,000 clients are kept, the least recently seen dropped first, with their
|
||||
history, and a restart gives no client a fresh allowance (see "State files"
|
||||
below).
|
||||
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
||||
describes: the first ban lasts an hour, and a limit broken again within a day
|
||||
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
||||
81 hours; a ban that would last longer than seven days is permanent instead. A
|
||||
ban covers the client's netblock: its IPv4 address, or the netblock around it
|
||||
that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64. While it lasts, every
|
||||
request from the netblock is refused with `SWWAF_BAN_RESPONSE` after the
|
||||
static lists and before the country lists, so the client is not looked up, and
|
||||
is not counted for the rate limits. A ban sets the client's counters back to
|
||||
zero. Each ban carries notes for deciding whether to lift it: the limit, its
|
||||
window and the requests counted in it, the request that broke it, the client's
|
||||
country when it was looked up, the netblock's requests since it was first
|
||||
seen, how many of them the ban has refused, and how many bans the netblock had
|
||||
before. At most `SWWAF_MAX_BANS` bans are kept, past, active and permanent;
|
||||
past that, the earliest ban of the netblock that has gone longest without a
|
||||
request is dropped first. `bans.json` shows the bans and their notes, a
|
||||
restart lifts none, and you add or lift a ban by editing it (see "State files"
|
||||
below).
|
||||
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
||||
as the client's country is known and before its body is read; such a request
|
||||
is not counted for the rate limits. While one of the country lists below is
|
||||
set, each client's country is looked up through GeoJS (see "Country and AS
|
||||
number lookup" below); with neither set, no visitor's address leaves the host.
|
||||
A client on a private, loopback or link-local address has no country and is
|
||||
never looked up: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is
|
||||
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
||||
- Checks the client's own address against the static lists, the three netblock
|
||||
settings below, before anything else, its country included. A client in
|
||||
`SWWAF_ALLOW_NETS` skips the country lists and the rate limits, and is not
|
||||
looked up; the timeouts and size limits still apply. A client in
|
||||
`SWWAF_DENY_NETS` is refused with `403` before its body is read, and the
|
||||
request is not counted for the rate limits; an address in `SWWAF_ALLOW_NETS`
|
||||
too is let through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither
|
||||
counted nor refused by the rate limits; the country lists still apply to it.
|
||||
`SWWAF_ALLOW_NETS` skips bans, the country lists and the rate limits, and is
|
||||
not looked up; the timeouts and size limits still apply. A client in
|
||||
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
|
||||
read, and the request is not counted for the rate limits; an address in
|
||||
`SWWAF_ALLOW_NETS` too is let through. A client in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
||||
limits; the country lists and bans still apply to it.
|
||||
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
|
||||
that `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would refuse:
|
||||
it passes them to the app, and their log lines name what `enforce` mode would
|
||||
have done (see `would_action` in "Request log" below). The checks run, and
|
||||
requests are counted, as in `enforce` mode, but a broken rate limit makes no
|
||||
ban and does not set the client's counters back to zero, so each request over
|
||||
the limit is logged as one that would be refused. The bans in `bans.json` are
|
||||
kept, and refuse requests again when `smallwebwaf` next runs in `enforce`
|
||||
mode, as long as they last. The timeouts and size limits still apply, since
|
||||
they protect `smallwebwaf` and the app themselves, and a request for the
|
||||
metrics without the token is still answered `401`. It is for trying a
|
||||
configuration before enforcing it.
|
||||
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
||||
check and without asking the app, for the image's health check.
|
||||
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
||||
a request that carries `SWWAF_METRICS_TOKEN` as
|
||||
`Authorization: Bearer <token>`, and with `401` for one that does not. While
|
||||
the token is unset the metrics answer `404`, as does any other request under
|
||||
`/_smallwebwaf/`. Unlike the health check, such a request goes through every
|
||||
check any other request goes through, and is answered where another would be
|
||||
passed to the app: a banned client stays refused, and each counts toward the
|
||||
client's rate limits. None of them reaches the app.
|
||||
- Writes a line in the request log for each request (see "Request log" below).
|
||||
|
||||
## Settings
|
||||
@@ -107,12 +157,30 @@ it, and the effective settings are logged at start.
|
||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens.
|
||||
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or
|
||||
`https`, a host and an optional port, and nothing more.
|
||||
- `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the
|
||||
first 12 characters of the container's id unless the deployment names one):
|
||||
the name each request log line gives as `instance`. Set it, for example to
|
||||
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
|
||||
container, and that tells instances apart when several log to one place.
|
||||
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
|
||||
requests `smallwebwaf` would refuse and log what it would have done (see "What
|
||||
it does so far" above).
|
||||
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`,
|
||||
the private address ranges): the netblocks whose `X-Forwarded-For` is
|
||||
believed. A list given replaces the default; set but empty, it trusts nothing.
|
||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
|
||||
send its request line and headers, and then, from the end of the headers, its
|
||||
body.
|
||||
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
||||
line and headers a client may send. Over it, the answer is `431` and nothing
|
||||
reaches the app. It must be more than `4K`, and cannot be `off`: Go's HTTP
|
||||
server always has such a limit, and reads 4 KiB past the one it is given
|
||||
before it refuses.
|
||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
||||
may wait for its next request before `smallwebwaf` closes it. The default is
|
||||
longer than the 90 seconds after which traefik closes a connection it is not
|
||||
using, so traefik never sends a request on a connection `smallwebwaf` is
|
||||
closing.
|
||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the response may
|
||||
take to reach the client, from the end of the request to the last byte.
|
||||
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
|
||||
@@ -121,8 +189,9 @@ it, and the effective settings are logged at start.
|
||||
to send its whole answer, from the end of the request to the last byte.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip the country
|
||||
lists and the rate limits, such as your monitoring or your own networks.
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
||||
country lists and the rate limits, such as your monitoring or your own
|
||||
networks.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
||||
rate limits do not apply to, such as a machine that talks to the app all day.
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
||||
@@ -137,6 +206,42 @@ it, and the effective settings are logged at start.
|
||||
countries whose clients get through, for example `us,de`. A client whose
|
||||
country cannot be found is refused too, so that new clients are not let in
|
||||
whenever GeoJS stops answering.
|
||||
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
|
||||
that is banned, breaks a rate limit, is in `SWWAF_DENY_NETS` or comes from a
|
||||
refused country: `403`, `429`, or `close` to close the connection without an
|
||||
answer. Behind traefik, `close` does not leave the client unanswered: traefik
|
||||
answers `502`, as it does whenever its backend drops a connection.
|
||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
||||
limit.
|
||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
||||
within this time after a ban ended bans for three times as long as that ban.
|
||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is
|
||||
permanent instead.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and
|
||||
permanent.
|
||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
||||
an IPv4 client that a ban covers, such as `24` to ban the surrounding /24. An
|
||||
IPv6 ban covers the client's /64.
|
||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
||||
files, an absolute path. A directory `smallwebwaf` cannot write stops the
|
||||
start.
|
||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long after a ban is made
|
||||
`bans.json` is written, with every ban made in between.
|
||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||
written.
|
||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||
request headers whose values the request log gives, in either case.
|
||||
`Authorization`, `Cookie` and `Set-Cookie` are never logged, even when listed
|
||||
(see "Request log" below). An entry naming `Host` or `Transfer-Encoding` stops
|
||||
the start, since Go's HTTP server takes both out of the request; the request's
|
||||
host is the field `host`.
|
||||
- `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for the
|
||||
metrics, a long random value. While it is unset the metrics are off; one
|
||||
shorter than 32 characters stops the start. The settings logged at start show
|
||||
`********` in its place.
|
||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
|
||||
own in the metrics by country; the others are counted as `other`.
|
||||
|
||||
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
||||
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
||||
@@ -145,16 +250,14 @@ and a bare address stands for itself alone. Countries are the two-letter codes
|
||||
ISO 3166-1 assigns today, and `xk` for Kosovo, in either case (`de` and `DE` are
|
||||
the same); any other code, such as `nk` (North Korea is `kp`) or the withdrawn
|
||||
`su`, stops the start, and so does a code on both country lists. `off` switches
|
||||
a timeout, a size limit or a rate limit off.
|
||||
a timeout, a size limit or a rate limit off;
|
||||
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, the ban settings, the state settings
|
||||
and `SWWAF_METRICS_TOP_N` cannot be off.
|
||||
|
||||
Several limits are fixed rather than settings. The request line and headers may
|
||||
take up to 32 KiB, above which the answer is `431` and nothing reaches the app.
|
||||
A kept-open connection that sends nothing for 120 seconds is closed. That is
|
||||
longer than the 90 seconds after which traefik closes a connection it is not
|
||||
using, so traefik never sends a request on a connection `smallwebwaf` is
|
||||
closing. At most 20,000 clients are kept for the rate limits, and an IPv6 client
|
||||
is counted by its /64. A new client waits at most a second for its country, and
|
||||
at most 100,000 answers from GeoJS are kept, for 7 days each.
|
||||
Several limits are fixed rather than settings. At most 20,000 clients are kept,
|
||||
with their counters and history, and an IPv6 client is counted by its /64. A new
|
||||
client waits at most a second for its country, and at most 100,000 answers from
|
||||
GeoJS are kept, for 7 days each.
|
||||
|
||||
## Request log
|
||||
|
||||
@@ -162,41 +265,217 @@ at most 100,000 answers from GeoJS are kept, for 7 days each.
|
||||
refused ones included:
|
||||
|
||||
```
|
||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","client_ip":"203.0.113.9","peer_ip":"172.18.0.2","country":"DE","method":"GET","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"upstream_status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","action":"forward","duration_total":3.217,"duration_upstream_total":3.104}
|
||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
||||
```
|
||||
|
||||
- `time` is when the request arrived, in UTC. `peer_ip` is the TCP peer,
|
||||
normally traefik. `path` and `query` are as the client sent them.
|
||||
- `country` is the client's country as GeoJS places it, and empty when it is not
|
||||
known: with neither country list set, for a client in `SWWAF_ALLOW_NETS` or
|
||||
`SWWAF_DENY_NETS`, for a client on a private, loopback or link-local address,
|
||||
and when GeoJS cannot place the client or has not answered in time.
|
||||
A field that does not apply to a request is left out of its line, apart from
|
||||
`type`, the fields from `time` to `user_agent`, `request_id`, `peer_ip`,
|
||||
`client_group`, `country`, `action` and `duration_total`, which every line has.
|
||||
|
||||
- `time` is when the request arrived, in UTC. `instance` is
|
||||
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
||||
sent, and otherwise `http`. `path` and `query` are as the client sent them.
|
||||
- `request_id` is the `X-Request-ID` a trusted proxy sent, or a new random one
|
||||
of 26 letters and digits when it sent none, or when the peer is not a trusted
|
||||
proxy. A request passed to the app takes it there in `X-Request-ID`.
|
||||
- `peer_ip` is the TCP peer, normally traefik. `forwarded_for` is the
|
||||
`X-Forwarded-For` header as received, several lines of it joined with `, `.
|
||||
`client_group` is the client as the rate limits count it: its IPv4 address as
|
||||
a /32, or the /64 of its IPv6 address.
|
||||
- `country` is the client's country as GeoJS places it. It is empty with neither
|
||||
country list set, for a client in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, for
|
||||
a client on a private, loopback or link-local address, when GeoJS cannot place
|
||||
the client or has not answered in time, and for a request whose client a ban
|
||||
covers, even when the client's country is known.
|
||||
- `content_type` is the request's `Content-Type`, and `content_length` the
|
||||
length the request announced for its body, which is left out for none or zero.
|
||||
- `request_headers` are the request's headers that `SWWAF_LOG_REQUEST_HEADERS`
|
||||
names, by name in lower case, several lines of one joined with `, `.
|
||||
`Authorization`, `Cookie` and `Set-Cookie` are never among them, whatever the
|
||||
setting says: `has_authorization` and `has_cookie` are there instead, and
|
||||
true, when the request has an `Authorization` or a `Cookie` header.
|
||||
- `websocket` is there, and true, when the app switched the connection to
|
||||
another protocol, as it does for a WebSocket.
|
||||
- `status` is what the client was sent, `0` if nothing was; `upstream_status` is
|
||||
what the app answered, and is left out when the app did not answer.
|
||||
- `response_content_type`, `cache_control` and `location` are the
|
||||
`Content-Type`, `Cache-Control` and `Location` headers of the answer: the
|
||||
app's, as passed on, or those of `smallwebwaf`'s own answer.
|
||||
- `request_bytes` and `response_bytes` count body bytes.
|
||||
- `action` is `forward` for a request passed to the app, `denied` for one
|
||||
refused because its client is in `SWWAF_DENY_NETS`, `country_denied` for one
|
||||
refused for its client's country, `rate_limited` for one refused for a rate
|
||||
limit, `too_large` for a request or response over its size limit, `timed_out`
|
||||
for one that ran out of time, `upstream_error` when the app could not be
|
||||
reached or its answer broke off, and `admin` for one `smallwebwaf` answered at
|
||||
its own endpoint.
|
||||
- `limit_hit` is there for a request refused for a rate limit, and names the
|
||||
refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused
|
||||
because a ban covers its client, `country_denied` for one refused for its
|
||||
client's country, `rate_limited` for one that broke a rate limit and banned
|
||||
its client, `too_large` for a request or response over its size limit,
|
||||
`timed_out` for one that ran out of time, `upstream_error` when the app could
|
||||
not be reached or its answer broke off, and `admin` for one `smallwebwaf`
|
||||
answered at its own endpoint.
|
||||
- `would_action` is there in `observe` mode for a request that
|
||||
`SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would have refused
|
||||
in `enforce` mode, and names the action that refusal would have had: `denied`,
|
||||
`banned`, `country_denied` or `rate_limited`. `action` then names what was
|
||||
done: `forward` for a request passed to the app, and another action, such as
|
||||
`too_large`, for one a size or time limit refused.
|
||||
- `counts` gives the client's requests in the minute, the hour and the day as
|
||||
the rate limits count them, this request included: in each window, those in
|
||||
the bucket under way and a share of those in the bucket before, so a count can
|
||||
have a fraction. For a request that broke a limit, they are the counts that
|
||||
broke it. It is left out for a request the rate limits do not count: the
|
||||
health check, one from a client in `SWWAF_ALLOW_NETS` or
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, and one that `SWWAF_DENY_NETS`, a ban or the
|
||||
country lists refuse, or would refuse in `observe` mode. The byte totals come
|
||||
with the byte limits.
|
||||
- `limit_hit` is there for a request that broke a rate limit, and names the
|
||||
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
||||
went over several.
|
||||
went over several. `offence` is then `limit`.
|
||||
- `ban_expires` is there for a request that made a ban or was refused under one,
|
||||
or in `observe` mode would have been refused under one, and gives when the ban
|
||||
ends, in the same form as `time`, or `permanent`.
|
||||
- `aborted` is there, and true, when the client went away early.
|
||||
- `duration_total` and `duration_upstream_total` are in milliseconds.
|
||||
- The timings are in milliseconds, to the microsecond. `duration_total` runs
|
||||
from when the request's headers had been read to when its line is written, and
|
||||
`duration_checks` over the same start to when the checks were done; the health
|
||||
check runs none, and its line has no `duration_checks`.
|
||||
`duration_upstream_connect`, `duration_upstream_first_byte` and
|
||||
`duration_upstream_total` are there for a request passed to the app, and run
|
||||
from when it was handed to the app: until there was a connection to it, new or
|
||||
kept open from an earlier request, until the first byte of its answer arrived,
|
||||
and until the end. The first two are left out when that never happened, as for
|
||||
an app that cannot be reached.
|
||||
|
||||
No body and no other header is logged. `smallwebwaf`'s own messages (start, the
|
||||
settings, stop, errors) share the stream as JSON lines marked
|
||||
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
||||
(start, the settings, stop, errors) share the stream as JSON lines marked
|
||||
`"type":"process"`.
|
||||
|
||||
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
||||
headers before `smallwebwaf` sees the request, and some requests end there,
|
||||
without a line in the log: headers over 32 KiB, which it answers `431`, headers
|
||||
slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`, whose connection it closes without
|
||||
an answer, and requests it cannot read at all, which it answers itself, mostly
|
||||
with `400`.
|
||||
without a line in the log: headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
||||
which it answers `431`, headers slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
||||
whose connection it closes without an answer, and requests it cannot read at
|
||||
all, which it answers itself, mostly with `400`.
|
||||
|
||||
## State files
|
||||
|
||||
`smallwebwaf` keeps its state in memory and a copy of it in three JSON files in
|
||||
`SWWAF_STATE_DIR`, `/var/lib/smallwebwaf` by default, as "Persistent state" in
|
||||
[`SPEC.md`](SPEC.md) describes. Each has a top-level `version`, 1, and lists its
|
||||
entries by client address, with times in UTC.
|
||||
|
||||
- `bans.json`: every ban with its notes, indented to be read; a permanent ban's
|
||||
`expires` is `null`.
|
||||
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
||||
and its history: when it was first and last seen, its country as last looked
|
||||
up and when, its requests, how many were forwarded and how many refused (one
|
||||
`smallwebwaf` answered at its own endpoints is neither, unless it was refused
|
||||
with `401` for a missing or wrong token), the body bytes in each direction,
|
||||
its responses by status class and its offences by kind. Each client is on a
|
||||
line of its own, so `grep` shows everything about one.
|
||||
- `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and
|
||||
when it was last used.
|
||||
|
||||
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, with every
|
||||
ban made in between, and every file every `SWWAF_STATE_COUNTER_INTERVAL` and
|
||||
when `smallwebwaf` stops. Each write goes to a temporary file in the same
|
||||
directory, which then replaces the file, so a crash leaves the old file or the
|
||||
new one, whole. A write that fails is logged, and tried again at the next write.
|
||||
A hard kill loses what changed since the last write.
|
||||
|
||||
At start the files are read back: each client keeps its counts, so a restart
|
||||
gives it no fresh allowance, and each ban keeps refusing every client in its
|
||||
netblock until it ends, even after `SWWAF_BAN_SCOPE_V4_PREFIX` has changed. A
|
||||
netblock whose address has bits past its length, such as `203.0.113.9/24`, is
|
||||
read as the netblock it is in, `203.0.113.0/24`. Buckets and answers whose time
|
||||
has passed are dropped. A missing file is empty state, as on a first start. A
|
||||
file that does not parse, or has another `version`, stops the start with a
|
||||
message naming the file, and the line and column where Go's JSON decoder gives
|
||||
them; so does a state directory `smallwebwaf` cannot write. So does an entry
|
||||
without a field it needs, named with the entry's place in the file: a ban's
|
||||
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
||||
client's `client`, or the `start` of a window in which it has requests; an
|
||||
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
||||
`answered`. The AS number and AS name come with their lookup.
|
||||
|
||||
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
||||
a state file as soon as you save it: what the file then holds replaces what
|
||||
`smallwebwaf` held for it, as if read at start. It tells its own writes from
|
||||
yours by comparing the file with what it last read or wrote, and before it
|
||||
writes a file it takes in any edit made since, so your edit is not overwritten;
|
||||
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
||||
lost when you save over it. An edit that would stop the start, because it does
|
||||
not parse, has another `version` or leaves out a field an entry needs, does not
|
||||
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
|
||||
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
|
||||
file again from memory, and logs the file and where the error is. It waits for
|
||||
that write because an editor's file can be read before the editor has finished
|
||||
writing it. Mend the `.bad` file and move it back. A file you remove is written
|
||||
again at its next write.
|
||||
|
||||
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
|
||||
and its `expires`, `null` for a ban that never ends; its `notes` may be left
|
||||
out. This `bans.json` bans `203.0.113.0/24` for good:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"bans": [
|
||||
{
|
||||
"netblock": "203.0.113.0/24",
|
||||
"start": "2026-10-06T12:00:00Z",
|
||||
"expires": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To lift a ban, delete its entry. `smallwebwaf` then forgets the ban, so it does
|
||||
not make the netblock's next ban longer.
|
||||
|
||||
## Metrics
|
||||
|
||||
`GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text
|
||||
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
|
||||
other request. No metric carries a client's address.
|
||||
|
||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
|
||||
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
|
||||
`action`, as the request log names it.
|
||||
- `smallwebwaf_request_duration_seconds`: how long requests took, and
|
||||
`smallwebwaf_upstream_duration_seconds`: how long those passed to the app took
|
||||
from then on, as histograms; `smallwebwaf_requests_in_flight`: the requests
|
||||
under way.
|
||||
- `smallwebwaf_rate_limit_hits_total` by `window`,
|
||||
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
||||
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
||||
`smallwebwaf_bans_made_total` by `cause`; `smallwebwaf_active_bans` and
|
||||
`smallwebwaf_permanent_bans`.
|
||||
- `smallwebwaf_country_requests_total`,
|
||||
`smallwebwaf_country_request_bytes_total`,
|
||||
`smallwebwaf_country_response_bytes_total`, and
|
||||
`smallwebwaf_country_list_refusals_total`, the requests the country lists
|
||||
refused, by `country`, for the requests whose client's country is known. The
|
||||
`SWWAF_METRICS_TOP_N` countries with the most requests since the start have
|
||||
series of their own, and the others are counted as `other`. A country that
|
||||
drops out of them loses its series, and its later requests count as `other`;
|
||||
one that comes into them gets a series that counts from then on.
|
||||
- `smallwebwaf_geojs_requests_total`: the requests to GeoJS;
|
||||
`smallwebwaf_geojs_failures_total`: those that failed, an answer that leaves
|
||||
out an address asked about included; and `smallwebwaf_geojs_unanswered_total`:
|
||||
the requests whose client counted as coming from an unknown country because
|
||||
GeoJS had not answered about it in time.
|
||||
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
|
||||
- `smallwebwaf_state_file_writes_total`,
|
||||
`smallwebwaf_state_file_write_failures_total`,
|
||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
||||
`smallwebwaf_state_file_size_bytes`, by `file`; and, by `file` too,
|
||||
`smallwebwaf_state_file_edits_taken_in_total`: your edits taken in, and
|
||||
`smallwebwaf_state_file_edits_set_aside_total`: those renamed to `<name>.bad`
|
||||
because they would stop the start.
|
||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||
|
||||
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
||||
log") are not counted. The metrics of the features still to come, such as the
|
||||
rule files, come with them.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -299,9 +578,9 @@ goes through the candidates one by one.
|
||||
readable JSON files, written regularly and at every stop, so a restart loses
|
||||
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
|
||||
up the change. Nothing is read from disk while serving a request. The files
|
||||
come in milestone 3 or later (see the build order in [`SPEC.md`](SPEC.md));
|
||||
until then the rate counters and the GeoJS answers are kept in memory only,
|
||||
and a restart loses them.
|
||||
for the bans, the clients and the GeoJS answers are built, with an edit taken
|
||||
in while running (see "State files" above); the others come with their
|
||||
features.
|
||||
- Health checks, the metrics, and listing, adding and lifting bans or asking why
|
||||
a given address was refused, all on the one port every request uses: under
|
||||
`/_smallwebwaf/` on the app's own address, through traefik like any other
|
||||
@@ -397,8 +676,9 @@ main "$@"
|
||||
the health check on `127.0.0.1`.
|
||||
- `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf`. Mount a volume
|
||||
there to keep bans and client history when a deploy replaces the container;
|
||||
without one, it still starts. The state files come in milestone 3 or later;
|
||||
until then it writes nothing to disk and needs no volume.
|
||||
without one, it still starts. At each start the `run` script of `smallwebwaf`
|
||||
gives that directory and every file in it to the `smallwebwaf` user, so a host
|
||||
directory mounted there needs no change of owner.
|
||||
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
|
||||
requests and gives those in progress five seconds to finish.
|
||||
|
||||
@@ -418,28 +698,27 @@ the metrics, failure behaviour and the build order.
|
||||
|
||||
So far `smallwebwaf` looks up only the country, only through GeoJS, and only
|
||||
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
|
||||
then the address of every new visitor outside `SWWAF_ALLOW_NETS` and
|
||||
`SWWAF_DENY_NETS` is sent to GeoJS, and with neither set, none is. An IPv6
|
||||
visitor is asked about by the first address of its /64. A new visitor waits at
|
||||
most a second for its answer, and without one counts as coming from an unknown
|
||||
country until the answer arrives. The addresses waiting are asked about
|
||||
together, up to 200 in one request, one request at a time; at most 10,000
|
||||
visitors wait, and one more counts as coming from an unknown country until there
|
||||
is room. While GeoJS fails, visitors with a kept answer are unaffected and new
|
||||
ones count as coming from an unknown country. GeoJS is then left alone for a
|
||||
second, twice as long after each further failure up to five minutes, and asked
|
||||
again by the next request that needs it.
|
||||
then the address of every new visitor is sent to GeoJS, except a visitor in
|
||||
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one whose netblock a ban covers, and
|
||||
with neither set, none is. An IPv6 visitor is asked about by the first address
|
||||
of its /64. A new visitor waits at most a second for its answer, and without one
|
||||
counts as coming from an unknown country until the answer arrives. The addresses
|
||||
waiting are asked about together, up to 200 in one request, one request at a
|
||||
time; at most 10,000 visitors wait, and one more counts as coming from an
|
||||
unknown country until there is room. While GeoJS fails, visitors with a kept
|
||||
answer are unaffected and new ones count as coming from an unknown country.
|
||||
GeoJS is then left alone for a second, twice as long after each further failure
|
||||
up to five minutes, and asked again by the next request that needs it.
|
||||
|
||||
In the full design, `smallwebwaf` looks up the AS number and country of every
|
||||
client, for the request log, the metrics and the ban notes, and for the country
|
||||
lists and biased limits when you set them. It works with no setup: by default it
|
||||
asks the free GeoJS web service, which needs no account and no file. This means
|
||||
that, by default, the address of every new visitor is sent to GeoJS. Each answer
|
||||
is kept in memory for seven days, and many addresses are asked about in one
|
||||
request; writing the answers to disk, so that they survive a restart, comes in
|
||||
milestone 3 or later (see the build order in [`SPEC.md`](SPEC.md)). GeoJS
|
||||
publishes no rate limit but may block a caller it thinks asks too much; while it
|
||||
is not answering, new visitors count as coming from an unknown country, which
|
||||
is kept for seven days, in memory and in `lookups.json`, so that it survives a
|
||||
restart, and many addresses are asked about in one request. GeoJS publishes no
|
||||
rate limit but may block a caller it thinks asks too much; while it is not
|
||||
answering, new visitors count as coming from an unknown country, which
|
||||
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses.
|
||||
|
||||
To keep your visitors' addresses on your own host, set
|
||||
@@ -470,21 +749,31 @@ addresses are never sent to GeoJS.
|
||||
## How the code is laid out
|
||||
|
||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||
- `internal/smallwebwaf`: the process: it reads the settings, listens, serves
|
||||
requests until `SIGTERM` or `SIGINT`, and stops. Run as
|
||||
`smallwebwaf healthcheck`, it is the image's health check instead.
|
||||
- `internal/smallwebwaf`: the process: it reads the settings and the state
|
||||
files, listens, serves requests until `SIGTERM` or `SIGINT`, and stops,
|
||||
writing the state files. Run as `smallwebwaf healthcheck`, it is the image's
|
||||
health check instead.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/proxy`: what happens to each request: it works out the client, runs
|
||||
the checks, passes the request to the app and the answer back with the
|
||||
standard library's `httputil.ReverseProxy` within the timeouts and size
|
||||
limits, and writes the request's log line. Its `check` method is where a
|
||||
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
|
||||
the country lists, for a rate limit, and for an announced body over the size
|
||||
limit.
|
||||
a ban, for the country lists, for a rate limit, which bans the client, and for
|
||||
an announced body over the size limit; in `observe` mode, only for the size
|
||||
limit, with what it would have refused for noted in the log line. A request
|
||||
under `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
||||
instead of reaching the app.
|
||||
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
||||
happened, and served in the Prometheus text format.
|
||||
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
||||
long a new ban lasts, and which ban is dropped when `SWWAF_MAX_BANS` are held.
|
||||
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
||||
answers.
|
||||
- `internal/ratelimit`: counts each client's requests and tells when one takes
|
||||
it over a rate limit.
|
||||
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
||||
tells when one takes it over a rate limit, and keeps each client's history.
|
||||
- `internal/state`: reads the state files at start, takes in an admin's edit of
|
||||
one while running, and writes them when they are due and at the stop.
|
||||
- `internal/requestlog`: the lines on stdout: the request log line and the
|
||||
process's own messages.
|
||||
- `Dockerfile`: the lint and test phases, then the image, whose last stage
|
||||
@@ -494,8 +783,11 @@ addresses are never sent to GeoJS.
|
||||
checks.
|
||||
|
||||
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
|
||||
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
|
||||
recently seen. The country codes are the list in `internal/config/config.go`.
|
||||
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
|
||||
netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen, and
|
||||
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
|
||||
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file is saved.
|
||||
The country codes are the list in `internal/config/config.go`.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
@@ -524,19 +816,22 @@ so that they run in minimal containers.
|
||||
- `script/install-precommit`: installs that hook; `make hooks` runs it.
|
||||
- `script/build`: builds `bin/smallwebwaf` on the host, with Go installed, for
|
||||
working on the code by hand; `make build` runs it.
|
||||
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it;
|
||||
`make run` runs it.
|
||||
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it, with
|
||||
its state files in `bin/state` unless `SWWAF_STATE_DIR` is set; `make run`
|
||||
runs it.
|
||||
- `script/example-app`: builds the image and, on it, the example app in
|
||||
`deploy/example-app`, runs it, and checks that the health check passes, that a
|
||||
request reaches the app through `smallwebwaf`, and that `sv stop` and
|
||||
`docker stop` stop it in order; then removes the container and both images. It
|
||||
needs network access, for nixpkgs' binary cache, and `script/check` does not
|
||||
run it; `make example-app` does.
|
||||
`deploy/example-app`, runs it with a volume for the state files, and checks
|
||||
that the health check passes, that a request reaches the app through
|
||||
`smallwebwaf`, that a second request in a minute bans the client, that
|
||||
`sv stop` and `docker stop` stop it in order, and that a new container on the
|
||||
same volume still refuses the banned client; then removes the containers, the
|
||||
volume and both images. It needs network access, for nixpkgs' binary cache,
|
||||
and `script/check` does not run it; `make example-app` does.
|
||||
|
||||
## TODO
|
||||
|
||||
- The rest of milestone 3, after the static lists, and the rest of the design,
|
||||
in the order of the build order in [`SPEC.md`](SPEC.md).
|
||||
- The rest of milestone 3: exemptions; then the rest of the design, in the order
|
||||
of the build order in [`SPEC.md`](SPEC.md).
|
||||
|
||||
## Documents
|
||||
|
||||
|
||||
@@ -293,7 +293,8 @@ it.
|
||||
needs: an alert destination, an account key, a token.
|
||||
- Every setting's name starts with `SWWAF_`, since `smallwebwaf` shares its
|
||||
container, and so its environment variables, with the app it protects.
|
||||
- Any limit or threshold can be switched off with the value `off`.
|
||||
- Any limit or threshold can be switched off with the value `off`, except
|
||||
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`.
|
||||
- A list set to an empty value is an empty list, and replaces the default.
|
||||
- Every setting may instead be given as a file holding the value, named by the
|
||||
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
|
||||
@@ -413,7 +414,9 @@ The settings, by group:
|
||||
headers, its body.
|
||||
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest
|
||||
request line and headers a client may send. Over it, `smallwebwaf` answers
|
||||
`431` and closes the connection, and nothing reaches the app.
|
||||
`431` and closes the connection, and nothing reaches the app. It must be
|
||||
more than `4K`, and cannot be `off`: Go's HTTP server always has such a
|
||||
limit, and reads 4 KiB past the one it is given before it refuses.
|
||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open
|
||||
connection may wait for its next request before `smallwebwaf` closes it.
|
||||
It is longer than the 90 seconds after which traefik, by default, closes a
|
||||
@@ -945,9 +948,9 @@ and the running `smallwebwaf` takes the edit in.
|
||||
- what was broken: the rule ids and target that matched, or the limit, its
|
||||
window, the count reached and the client's limit percentage with what set
|
||||
it; and any reputation sources that listed the client;
|
||||
- the requests that caused the ban, up to the last ten: time, method, host,
|
||||
path with its query string, status and user agent, each text cut to 256
|
||||
bytes;
|
||||
- the request that caused the ban, the one that broke the limit or carried
|
||||
the clear sign of attack: time, method, host, path with its query string,
|
||||
status and user agent, each text cut to 256 bytes;
|
||||
- how many requests counted toward the ban, and the time span over which
|
||||
they came;
|
||||
- the netblock's total requests since it was first seen, and the requests
|
||||
@@ -958,13 +961,13 @@ and the running `smallwebwaf` takes the edit in.
|
||||
the table is full, so on a public service the file grows to the default
|
||||
`SWWAF_MAX_TRACKED_CLIENTS` of 20,000, about 20 MiB. Written every 15
|
||||
minutes, that is under 2 GiB of disk writes a day.
|
||||
- `bans.json` takes about 2 KiB per ban and at most about 8 KiB, since the
|
||||
texts in the notes are cut short. At the default `SWWAF_MAX_BANS` of 5,000
|
||||
it is about 10 MiB, and never more than about 40 MiB, plus whatever bans
|
||||
an admin made. It is written when a ban is made, lifted or made permanent,
|
||||
at most once every 10 seconds, and otherwise with the 15-minute write, so
|
||||
its writes follow the bans made: with a full file, a hundred new bans a
|
||||
day come to about 1 GiB of disk writes.
|
||||
- `bans.json` takes about 1.2 KiB per ban and at most about 2.5 KiB, since
|
||||
the notes hold one request and their texts are cut short. At the default
|
||||
`SWWAF_MAX_BANS` of 5,000 it is about 6 MiB, and never more than about 12
|
||||
MiB, plus whatever bans an admin made. It is written when a ban is made,
|
||||
lifted or made permanent, at most once every 10 seconds, and otherwise
|
||||
with the 15-minute write, so its writes follow the bans made: with a full
|
||||
file, a hundred new bans a day come to about 600 MiB of disk writes.
|
||||
- `lookups.json` takes about 150 bytes per answer, about 15 MiB when full.
|
||||
Written every 15 minutes, that is under 1.5 GiB of disk writes a day.
|
||||
- `reputation.json` and `alerts.json` are usually a few MiB or less.
|
||||
|
||||
@@ -2,4 +2,20 @@ module sneak.berlin/go/smallwebwaf
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,40 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
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/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||
// netblocks of clients that break a rate limit, with their notes, as the
|
||||
// "Bans" section of SPEC.md describes. The bans are kept in memory, and
|
||||
// written to bans.json and read from it by the state package.
|
||||
package bans
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
)
|
||||
|
||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||
// for a limit broken again within the repeat window lasts.
|
||||
const repeatFactor = 3
|
||||
|
||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||
const maxTextBytes = 256
|
||||
|
||||
// Rules are how long a ban for a broken limit lasts, and how many bans
|
||||
// are held.
|
||||
type Rules struct {
|
||||
// LimitBanDuration is how long a first ban lasts.
|
||||
LimitBanDuration time.Duration
|
||||
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||
// ban that ended last a broken limit counts as a repeat, which bans
|
||||
// for repeatFactor times as long as that ban.
|
||||
LimitBanRepeatWindow time.Duration
|
||||
// MaxBanDuration is the longest ban; a ban that would be longer is
|
||||
// permanent instead.
|
||||
MaxBanDuration time.Duration
|
||||
// MaxBans is the most bans held, at least one. Past it, the earliest
|
||||
// ban of the netblock that has gone longest without a request is
|
||||
// dropped.
|
||||
MaxBans int
|
||||
}
|
||||
|
||||
// Ban is a ban on a netblock for a broken limit, the only kind of ban
|
||||
// smallwebwaf makes so far.
|
||||
type Ban struct {
|
||||
Netblock netip.Prefix
|
||||
Start time.Time
|
||||
// Expires is when the ban ends, zero for a permanent ban.
|
||||
Expires time.Time
|
||||
Notes Notes
|
||||
}
|
||||
|
||||
// Permanent reports whether the ban never runs out.
|
||||
func (b Ban) Permanent() bool {
|
||||
return b.Expires.IsZero()
|
||||
}
|
||||
|
||||
// ActiveAt reports whether the ban refuses requests at now.
|
||||
func (b Ban) ActiveAt(now time.Time) bool {
|
||||
return b.Permanent() || now.Before(b.Expires)
|
||||
}
|
||||
|
||||
// Notes are what an admin needs to decide whether to lift a ban. The
|
||||
// JSON names are those of bans.json.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Notes struct {
|
||||
// Country is the client's country, when it was looked up.
|
||||
Country string `json:"country"`
|
||||
// Limit, Window and Count are the limit that was broken, its window,
|
||||
// "minute", "hour" or "day", and the count reached: the client's
|
||||
// requests in the window, the one that broke the limit included.
|
||||
// These are the requests that counted toward the ban, and the window
|
||||
// is the time over which they came.
|
||||
Limit int64 `json:"limit"`
|
||||
Window string `json:"window"`
|
||||
Count float64 `json:"count"`
|
||||
// Request is the request that broke the limit.
|
||||
Request Request `json:"request"`
|
||||
// Requests is how many requests the netblock has sent since it was
|
||||
// first seen, and Refused how many of them the ban has refused so
|
||||
// far. Both go up with each request the ban refuses.
|
||||
Requests int64 `json:"requests"`
|
||||
Refused int64 `json:"refused"`
|
||||
// EarlierBans is how many bans the netblock had before this one.
|
||||
EarlierBans int `json:"earlier_bans"`
|
||||
}
|
||||
|
||||
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Request struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
// Path is the path with its query string.
|
||||
Path string `json:"path"`
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
Status int `json:"status"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// Ledger holds the bans. It is safe for concurrent use.
|
||||
type Ledger struct {
|
||||
rules Rules
|
||||
// changed receives a value when a ban is made, unless one is waiting
|
||||
// already.
|
||||
changed chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
// netblocks holds each banned netblock's bans, oldest first. Check and
|
||||
// Find make each netblock they find the most recently seen.
|
||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||
// held is how many bans netblocks holds, at most rules.MaxBans.
|
||||
held int
|
||||
// made is how many bans BanForLimit has made since the start.
|
||||
made int
|
||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||
// netblocks that have been banned. Check looks for a ban at each of
|
||||
// them, so that a ban read from bans.json refuses every client in its
|
||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
||||
// or another length of an IPv6 client's netblock.
|
||||
v4Lengths, v6Lengths []int
|
||||
}
|
||||
|
||||
// New returns a Ledger with no ban yet.
|
||||
func New(rules Rules) *Ledger {
|
||||
// Every netblock held has a ban, so there are never more netblocks
|
||||
// than rules.MaxBans, and the LRU never drops one itself.
|
||||
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](rules.MaxBans, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &Ledger{
|
||||
rules: rules,
|
||||
changed: make(chan struct{}, 1),
|
||||
netblocks: netblocks,
|
||||
}
|
||||
}
|
||||
|
||||
// Changed receives a value after a ban is made, so that bans.json can be
|
||||
// written. Several bans made before it is read leave one value.
|
||||
func (l *Ledger) Changed() <-chan struct{} {
|
||||
return l.changed
|
||||
}
|
||||
|
||||
// Check is called for a request from client, at now. It reports whether
|
||||
// a ban on a netblock client is in is active, and returns that ban, with
|
||||
// the request counted among those it refused.
|
||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false
|
||||
}
|
||||
|
||||
ban.Notes.Requests++
|
||||
ban.Notes.Refused++
|
||||
|
||||
return *ban, true
|
||||
}
|
||||
|
||||
// Find is Check without counting the request among those the ban
|
||||
// refused: in observe mode a ban refuses nothing.
|
||||
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
}
|
||||
|
||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||
// is active at now, or nil when none is. If several are, it returns the
|
||||
// one that started last. Every ban is looked at, since a ban an admin adds
|
||||
// to bans.json can start before the netblock's others and outlast them.
|
||||
func activeBan(bans []Ban, now time.Time) *Ban {
|
||||
for i := len(bans) - 1; i >= 0; i-- {
|
||||
if bans[i].ActiveAt(now) {
|
||||
return &bans[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
||||
// LimitBanRepeatWindow after the netblock's ban that ended last lasts
|
||||
// repeatFactor times as long as that one. A ban that would be longer
|
||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// active, as when two of its requests break a limit at once, that ban is
|
||||
// returned and no other is made. The ledger fills in the notes' Refused
|
||||
// and EarlierBans itself.
|
||||
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var last *Ban
|
||||
|
||||
bans, found := l.netblocks.Get(netblock)
|
||||
if found {
|
||||
active := activeBan(*bans, now)
|
||||
if active != nil {
|
||||
return *active
|
||||
}
|
||||
|
||||
// No ban is active, so each has an end. A ban an admin adds to
|
||||
// bans.json can start after another and end before it, so the
|
||||
// ban that ended last is looked for among them all.
|
||||
ended := slices.MaxFunc(*bans, func(a, b Ban) int {
|
||||
return a.Expires.Compare(b.Expires)
|
||||
})
|
||||
last = &ended
|
||||
|
||||
// The netblock's first ban held counts the bans it had before that
|
||||
// one, since dropped to make room, and each ban held adds one.
|
||||
notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans)
|
||||
}
|
||||
|
||||
notes.Request = notes.Request.cut()
|
||||
ban := Ban{
|
||||
Netblock: netblock,
|
||||
Start: now,
|
||||
Expires: l.expiry(last, now),
|
||||
Notes: notes,
|
||||
}
|
||||
l.add(ban)
|
||||
l.made++
|
||||
|
||||
select {
|
||||
case l.changed <- struct{}{}:
|
||||
default: // a value is waiting already
|
||||
}
|
||||
|
||||
return ban
|
||||
}
|
||||
|
||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||
// request from netblock, and leaves when it was last seen unchanged.
|
||||
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
bans, found := l.netblocks.Peek(netblock)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slices.Clone(*bans)
|
||||
}
|
||||
|
||||
// Made returns how many bans the ledger has made since the start; bans
|
||||
// read from bans.json are not among them.
|
||||
func (l *Ledger) Made() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.made
|
||||
}
|
||||
|
||||
// Count returns how many of the bans held are active at now, and how many
|
||||
// are permanent.
|
||||
func (l *Ledger) Count(now time.Time) (int, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
active, permanent := 0, 0
|
||||
|
||||
for _, bans := range l.netblocks.Values() {
|
||||
for _, ban := range *bans {
|
||||
if ban.ActiveAt(now) {
|
||||
active++
|
||||
}
|
||||
|
||||
if ban.Permanent() {
|
||||
permanent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return active, permanent
|
||||
}
|
||||
|
||||
// Snapshot returns every ban held, sorted by netblock, and each
|
||||
// netblock's bans oldest first, as bans.json lists them.
|
||||
func (l *Ledger) Snapshot() []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
held := make([]Ban, 0, l.held)
|
||||
for _, bans := range l.netblocks.Values() {
|
||||
held = append(held, *bans...)
|
||||
}
|
||||
|
||||
slices.SortStableFunc(held, func(a, b Ban) int {
|
||||
return a.Netblock.Compare(b.Netblock)
|
||||
})
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
// Load puts bans read from bans.json into the ledger, in place of the
|
||||
// bans it holds, in the order they started, so that a netblock whose last
|
||||
// ban started latest counts as the most recently seen. Each netblock is
|
||||
// masked to its length, so that 203.0.113.9/24 is 203.0.113.0/24, and
|
||||
// each text in the notes is cut to 256 bytes. Past MaxBans the earliest
|
||||
// bans are dropped, as when they are made.
|
||||
func (l *Ledger) Load(bans []Ban) {
|
||||
bans = slices.Clone(bans)
|
||||
slices.SortStableFunc(bans, func(a, b Ban) int {
|
||||
return a.Start.Compare(b.Start)
|
||||
})
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.netblocks.Purge()
|
||||
l.held = 0
|
||||
l.v4Lengths, l.v6Lengths = nil, nil
|
||||
|
||||
for _, ban := range bans {
|
||||
ban.Netblock = ban.Netblock.Masked()
|
||||
ban.Notes.Request = ban.Notes.Request.cut()
|
||||
l.add(ban)
|
||||
}
|
||||
}
|
||||
|
||||
// active returns the ban active at now on a netblock client is in, or
|
||||
// nil.
|
||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
lengths := l.v6Lengths
|
||||
if client.Is4() {
|
||||
lengths = l.v4Lengths
|
||||
}
|
||||
|
||||
for _, length := range lengths {
|
||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
ban := activeBan(*bans, now)
|
||||
if ban != nil {
|
||||
return ban
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// add adds ban to its netblock's bans, after the last, and makes its
|
||||
// netblock the most recently seen. With MaxBans held, it drops one first.
|
||||
func (l *Ledger) add(ban Ban) {
|
||||
if l.held == l.rules.MaxBans {
|
||||
l.dropOne()
|
||||
}
|
||||
|
||||
// dropOne can have dropped the netblock's last ban, and the netblock
|
||||
// with it.
|
||||
bans, found := l.netblocks.Get(ban.Netblock)
|
||||
if !found {
|
||||
bans = &[]Ban{}
|
||||
l.netblocks.Add(ban.Netblock, bans)
|
||||
}
|
||||
|
||||
*bans = append(*bans, ban)
|
||||
l.held++
|
||||
|
||||
lengths := &l.v6Lengths
|
||||
if ban.Netblock.Addr().Is4() {
|
||||
lengths = &l.v4Lengths
|
||||
}
|
||||
|
||||
if !slices.Contains(*lengths, ban.Netblock.Bits()) {
|
||||
*lengths = append(*lengths, ban.Netblock.Bits())
|
||||
}
|
||||
}
|
||||
|
||||
// expiry returns when a ban for a broken limit made at now ends, or zero
|
||||
// when it is permanent. last is the netblock's ban that ended last, or nil
|
||||
// when it has none.
|
||||
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
||||
length := l.rules.LimitBanDuration
|
||||
|
||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
||||
lastLength := last.Expires.Sub(last.Start)
|
||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||
// that it cannot overflow.
|
||||
if lastLength > l.rules.MaxBanDuration/repeatFactor {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
length = repeatFactor * lastLength
|
||||
}
|
||||
|
||||
if length > l.rules.MaxBanDuration {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return now.Add(length)
|
||||
}
|
||||
|
||||
// dropOne drops the earliest ban of the netblock that has gone longest
|
||||
// without a request, and the netblock with it if that was its only ban.
|
||||
func (l *Ledger) dropOne() {
|
||||
netblock, bans, _ := l.netblocks.GetOldest()
|
||||
if len(*bans) == 1 {
|
||||
l.netblocks.Remove(netblock)
|
||||
} else {
|
||||
*bans = slices.Delete(*bans, 0, 1)
|
||||
}
|
||||
|
||||
l.held--
|
||||
}
|
||||
|
||||
// cut returns r with each text cut to maxTextBytes and copied, so that
|
||||
// the notes do not keep the rest of the request in memory.
|
||||
func (r Request) cut() Request {
|
||||
r.Method = cutText(r.Method)
|
||||
r.Host = cutText(r.Host)
|
||||
r.Path = cutText(r.Path)
|
||||
r.UserAgent = cutText(r.UserAgent)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// cutText returns a copy of the first maxTextBytes of text.
|
||||
func cutText(text string) string {
|
||||
return strings.Clone(text[:min(len(text), maxTextBytes)])
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
const day = 24 * time.Hour
|
||||
|
||||
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
||||
// 81 hours.
|
||||
for i, hours := range []int{1, 3, 9, 27, 81} {
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
|
||||
length := time.Duration(hours) * time.Hour
|
||||
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
||||
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
||||
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
||||
}
|
||||
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
// The sixth would last 243 hours, more than seven days: it is
|
||||
// permanent, and never ends.
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
||||
if !banned {
|
||||
t.Error("a permanent ban ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// gap is the time between the end of the first ban and the second.
|
||||
gap time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"broken again as the window ends", day, 3 * time.Hour},
|
||||
{"broken again after the window", day + time.Nanosecond, time.Hour},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||
|
||||
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
||||
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
||||
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rules := defaultRules()
|
||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(rules)
|
||||
|
||||
ban := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
||||
// hours, is within the maximum, and three times as long would not fit
|
||||
// in a time.Duration. The 15th is permanent.
|
||||
rules := defaultRules()
|
||||
rules.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
for i := range 14 {
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Expires.After(ban.Start) {
|
||||
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
||||
}
|
||||
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
again := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
|
||||
if again != first || len(ledger.Bans(netblock)) != 1 {
|
||||
t.Errorf("a limit broken during a ban gave %+v and %d bans, want %+v and 1",
|
||||
again, len(ledger.Bans(netblock)), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
for range 3 {
|
||||
got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || got.Start != ban.Start {
|
||||
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
||||
}
|
||||
}
|
||||
|
||||
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
||||
if banned {
|
||||
t.Error("another netblock is banned")
|
||||
}
|
||||
|
||||
_, banned = ledger.Check(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
|
||||
// The netblock's requests went from 5 to 8 with the three refused.
|
||||
notes := ledger.Bans(netblock)[0].Notes
|
||||
if notes.Refused != 3 || notes.Requests != 8 {
|
||||
t.Errorf("the notes count %d refused requests of %d, want 3 of 8",
|
||||
notes.Refused, notes.Requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCountsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
got, banned := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || got != ban {
|
||||
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
||||
}
|
||||
|
||||
_, banned = ledger.Find(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
|
||||
if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes {
|
||||
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
a := netip.MustParsePrefix("203.0.113.1/32")
|
||||
b := netip.MustParsePrefix("203.0.113.2/32")
|
||||
c := netip.MustParsePrefix("203.0.113.3/32")
|
||||
d := netip.MustParsePrefix("2001:db8::/64")
|
||||
now := midnight()
|
||||
|
||||
first := ledger.BanForLimit(a, now, bans.Notes{})
|
||||
ledger.BanForLimit(b, now, bans.Notes{})
|
||||
ledger.BanForLimit(c, now, bans.Notes{})
|
||||
|
||||
// A request from a makes b the netblock seen longest ago, and its ban
|
||||
// goes to make room for d's.
|
||||
ledger.Check(a.Addr(), now)
|
||||
ledger.BanForLimit(d, now, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1})
|
||||
|
||||
// a is banned again once its ban has ended; c, seen longest ago, goes.
|
||||
ledger.BanForLimit(a, first.Expires, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 2, c: 0, d: 1})
|
||||
|
||||
// With d seen since, a is seen longest ago, and its earlier ban goes
|
||||
// first.
|
||||
ledger.Check(d.Addr(), first.Expires)
|
||||
ledger.BanForLimit(b, first.Expires, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
|
||||
|
||||
if !ledger.Bans(a)[0].Start.Equal(first.Expires) {
|
||||
t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With room for one ban, the netblock's ended ban goes to make room for
|
||||
// its new one, whose notes still count it.
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
||||
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
||||
held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
long := strings.Repeat("a", 300)
|
||||
request := bans.Request{
|
||||
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
||||
}
|
||||
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
||||
|
||||
cut := long[:256]
|
||||
want := bans.Request{
|
||||
Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut,
|
||||
}
|
||||
|
||||
if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want {
|
||||
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRules are the rules at the settings' defaults.
|
||||
func defaultRules() bans.Rules {
|
||||
return bans.Rules{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
MaxBans: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// midnight is when the tests' first bans are made.
|
||||
func midnight() time.Time {
|
||||
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// wantBans checks how many bans the ledger holds on each netblock.
|
||||
func wantBans(t *testing.T, ledger *bans.Ledger, want map[netip.Prefix]int) {
|
||||
t.Helper()
|
||||
|
||||
for netblock, count := range want {
|
||||
got := len(ledger.Bans(netblock))
|
||||
if got != count {
|
||||
t.Errorf("%s has %d bans, want %d", netblock, got, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// A limit broken during the ban makes no other, and a refusal changes
|
||||
// only the counts in the notes, which wait for the interval's write.
|
||||
ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
ledger.Check(netblock.Addr(), midnight().Add(time.Minute))
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
// Two bans before the value is read leave one.
|
||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.11/32"), midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
wantChanged(t, ledger, false)
|
||||
}
|
||||
|
||||
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
v6 := netip.MustParsePrefix("2001:db8::/64")
|
||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
||||
|
||||
snapshot := ledger.Snapshot()
|
||||
|
||||
got := make([]string, 0, len(snapshot))
|
||||
for _, ban := range snapshot {
|
||||
got = append(got, ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen))
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"203.0.113.9/32 12:00AM", "203.0.113.10/32 12:00AM",
|
||||
"2001:db8::/64 12:00AM", "2001:db8::/64 1:00AM",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("snapshot %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedBansCarryOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||
|
||||
// Loaded into a new ledger, as across a restart, the ban still refuses
|
||||
// while it lasts, and once it has ended a broken limit bans for three
|
||||
// times as long, with the loaded ban counted among the earlier ones.
|
||||
after := bans.New(defaultRules())
|
||||
after.Load(before.Snapshot())
|
||||
|
||||
_, banned := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||
if !banned {
|
||||
t.Error("the loaded ban does not refuse")
|
||||
}
|
||||
|
||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
||||
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Two entries as an admin might write them, with addresses not masked
|
||||
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
|
||||
// ban covers, beside a ban the ledger makes on one IPv4 address.
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
||||
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
||||
})
|
||||
ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{})
|
||||
|
||||
for client, want := range map[string]bool{
|
||||
"203.0.113.0": true,
|
||||
"203.0.113.200": true,
|
||||
"203.0.114.1": false,
|
||||
"2001:db8:0:5::1": true,
|
||||
"2001:db8:1::1": false,
|
||||
"198.51.100.7": true,
|
||||
"198.51.100.8": false,
|
||||
} {
|
||||
_, banned := ledger.Check(netip.MustParseAddr(client), midnight())
|
||||
if banned != want {
|
||||
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The loaded netblocks are written back masked.
|
||||
snapshot := ledger.Snapshot()
|
||||
|
||||
got := make([]string, 0, len(snapshot))
|
||||
for _, ban := range snapshot {
|
||||
got = append(got, ban.Netblock.String())
|
||||
}
|
||||
|
||||
want := []string{"198.51.100.7/32", "203.0.113.0/24", "2001:db8::/48"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("the ledger holds bans on %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As when an admin adds a permanent ban to bans.json with a start
|
||||
// before that of the netblock's ban that has ended.
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
|
||||
ended := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{permanent, ended})
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
|
||||
ban, banned := ledger.Find(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
||||
}
|
||||
|
||||
ban, banned = ledger.Check(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
||||
banned, ban)
|
||||
}
|
||||
|
||||
// A limit broken now makes no shorter ban over the permanent one.
|
||||
ban = ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
||||
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
||||
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
||||
// 1-hour ban added to bans.json over it, with no notes.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
nineHours := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(9 * time.Hour),
|
||||
Notes: bans.Notes{EarlierBans: 2},
|
||||
}
|
||||
admins := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight().Add(time.Hour),
|
||||
Expires: midnight().Add(2 * time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{nineHours, admins})
|
||||
|
||||
// Once both have ended, a limit broken within the repeat window bans
|
||||
// for three times the 9 hours, and the notes count the two bans
|
||||
// before the 9-hour one, it, and the admin's.
|
||||
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
|
||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
|
||||
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// bans.json lists the bans by netblock, not in the order they began.
|
||||
later := bans.Ban{Netblock: netip.MustParsePrefix("203.0.113.1/32"), Start: midnight()}
|
||||
earlier := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||
Start: midnight().Add(-time.Hour),
|
||||
}
|
||||
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
ledger.Load([]bans.Ban{later, earlier})
|
||||
|
||||
held := ledger.Snapshot()
|
||||
if len(held) != 1 || held[0] != later {
|
||||
t.Errorf("the ledger holds %+v, want only the ban that began later", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReplacesTheBansHeld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Room for three bans, so that the second load, were it added to the
|
||||
// two bans held, would drop none of them to make room.
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
kept := bans.Ban{Netblock: netip.MustParsePrefix("2001:db8::/64"), Start: midnight()}
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: netip.MustParsePrefix("203.0.113.0/24"), Start: midnight()},
|
||||
kept,
|
||||
})
|
||||
|
||||
// Loaded again without the first ban, as when an admin's edit of
|
||||
// bans.json is taken in, that ban is lifted.
|
||||
ledger.Load([]bans.Ban{kept})
|
||||
|
||||
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
||||
if banned {
|
||||
t.Error("a ban left out of the second load still refuses")
|
||||
}
|
||||
|
||||
// The ledger holds one ban, so it makes two more without dropping any.
|
||||
first := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
||||
bans.Notes{})
|
||||
second := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
||||
bans.Notes{})
|
||||
|
||||
want := []bans.Ban{first, second, kept}
|
||||
if got := ledger.Snapshot(); !slices.Equal(got, want) {
|
||||
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("a", 300)
|
||||
ban := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.9/32"),
|
||||
Start: midnight(),
|
||||
Notes: bans.Notes{Request: bans.Request{
|
||||
Method: long, Host: long, Path: long, UserAgent: long,
|
||||
}},
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{ban})
|
||||
|
||||
cut := long[:256]
|
||||
want := bans.Request{Method: cut, Host: cut, Path: cut, UserAgent: cut}
|
||||
|
||||
got := ledger.Snapshot()[0].Notes.Request
|
||||
if got != want {
|
||||
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wantChanged checks whether the ledger's Changed has a value to read.
|
||||
func wantChanged(t *testing.T, ledger *bans.Ledger, want bool) {
|
||||
t.Helper()
|
||||
|
||||
got := false
|
||||
|
||||
select {
|
||||
case <-ledger.Changed():
|
||||
got = true
|
||||
default:
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Changed has a value: %t, want %t", got, want)
|
||||
}
|
||||
}
|
||||
+292
-5
@@ -9,12 +9,16 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
|
||||
@@ -24,12 +28,28 @@ type Config struct {
|
||||
ListenAddr string
|
||||
// UpstreamURL is the app (SWWAF_UPSTREAM_URL).
|
||||
UpstreamURL *url.URL
|
||||
// InstanceName is the name each request log line gives as instance
|
||||
// (SWWAF_INSTANCE_NAME), by default the host's name, which docker sets
|
||||
// to the first 12 characters of the container's id.
|
||||
InstanceName string
|
||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
||||
// lists or a rate limit would refuse is passed to the app instead, and
|
||||
// no ban is made.
|
||||
Observe bool
|
||||
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
||||
// believed (SWWAF_TRUSTED_PROXIES).
|
||||
TrustedProxies []netip.Prefix
|
||||
// ClientRequestTimeout bounds reading the whole request from the
|
||||
// client (SWWAF_CLIENT_REQUEST_TIMEOUT).
|
||||
ClientRequestTimeout time.Duration
|
||||
// ClientRequestHeaderMaxBytes is the largest request line and headers
|
||||
// a client may send (SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES). It is
|
||||
// never off, and always more than 4K.
|
||||
ClientRequestHeaderMaxBytes int64
|
||||
// ClientIdleTimeout bounds how long a kept-open client connection
|
||||
// may wait for its next request (SWWAF_CLIENT_IDLE_TIMEOUT).
|
||||
ClientIdleTimeout time.Duration
|
||||
// ClientResponseTimeout bounds writing the whole response to the
|
||||
// client (SWWAF_CLIENT_RESPONSE_TIMEOUT).
|
||||
ClientResponseTimeout time.Duration
|
||||
@@ -67,6 +87,42 @@ type Config struct {
|
||||
// capitals, as GeoJS gives them.
|
||||
DeniedCountries []string
|
||||
ExclusivelyAllowedCountries []string
|
||||
// BanResponse is the status a refused client is answered with, 403
|
||||
// or 429, or 0 to close the connection without an answer
|
||||
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
||||
// breaks a rate limit, SWWAF_DENY_NETS and the country lists.
|
||||
BanResponse int
|
||||
// LimitBanDuration is the ban for a first broken rate limit
|
||||
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
|
||||
// LimitBanRepeatWindow after the last ban ended bans for three times
|
||||
// as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
||||
// would be longer than MaxBanDuration is permanent instead
|
||||
// (SWWAF_MAX_BAN_DURATION). None of them can be off.
|
||||
LimitBanDuration time.Duration
|
||||
LimitBanRepeatWindow time.Duration
|
||||
MaxBanDuration time.Duration
|
||||
// MaxBans is the most bans held (SWWAF_MAX_BANS).
|
||||
MaxBans int
|
||||
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
||||
// client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX).
|
||||
BanScopeV4Prefix int
|
||||
// StateDir is the directory of the state files, an absolute path
|
||||
// (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
||||
// is made (SWWAF_STATE_WRITE_DELAY), and every state file every
|
||||
// StateCounterInterval (SWWAF_STATE_COUNTER_INTERVAL). Neither can be
|
||||
// off.
|
||||
StateDir string
|
||||
StateWriteDelay time.Duration
|
||||
StateCounterInterval time.Duration
|
||||
// LogRequestHeaders are the request headers whose values the request
|
||||
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
||||
LogRequestHeaders []string
|
||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||
// MetricsTopN is how many countries get series of their own in the
|
||||
// metrics (SWWAF_METRICS_TOP_N).
|
||||
MetricsToken string
|
||||
MetricsTopN int
|
||||
|
||||
// settings are the values read, as given or by default, for the
|
||||
// log line at start.
|
||||
@@ -82,6 +138,11 @@ const (
|
||||
kibibyte = 1 << 10
|
||||
mebibyte = 1 << 20
|
||||
gibibyte = 1 << 30
|
||||
ipv4Bits = 32
|
||||
// minTokenLength is the fewest characters a token may have.
|
||||
minTokenLength = 32
|
||||
// masked is what the log shows for a token that is set.
|
||||
masked = "********"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -102,7 +163,24 @@ var (
|
||||
"such as http://127.0.0.1:8081")
|
||||
errNotCountry = errors.New(
|
||||
"is not a two-letter country code such as de or kp")
|
||||
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
||||
errNotHeaderName = errors.New(
|
||||
"is not a header name such as accept-language")
|
||||
errHeaderTakenOut = errors.New(
|
||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||
"be logged")
|
||||
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
||||
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d")
|
||||
errNotNumberAboveZero = errors.New(
|
||||
"is not a whole number above zero, such as 5000")
|
||||
errNotBanResponse = errors.New("is not 403, 429 or close")
|
||||
errNotV4Prefix = errors.New(
|
||||
"is not the length of an IPv4 netblock, from 0 to 32, such as 24")
|
||||
errNotAbsolutePath = errors.New(
|
||||
"is not an absolute path, such as /var/lib/smallwebwaf")
|
||||
errShortToken = errors.New("is shorter than 32 characters")
|
||||
errNotMode = errors.New("is not enforce or observe")
|
||||
)
|
||||
|
||||
// FromEnvironment reads the settings with lookupEnv, normally
|
||||
@@ -110,11 +188,17 @@ var (
|
||||
// that is set but invalid is an error that names it.
|
||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||
cfg := &Config{
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR", ":8080"),
|
||||
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"),
|
||||
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
||||
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR", ":8080"),
|
||||
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"),
|
||||
InstanceName: env.value("SWWAF_INSTANCE_NAME", hostname),
|
||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
||||
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
||||
ClientRequestHeaderMaxBytes: env.headerSize(
|
||||
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
|
||||
ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"),
|
||||
ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"),
|
||||
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
|
||||
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
|
||||
@@ -129,6 +213,19 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
||||
ExclusivelyAllowedCountries: env.countries(
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
||||
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
||||
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
|
||||
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
|
||||
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
|
||||
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
|
||||
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"),
|
||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
}
|
||||
|
||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||
@@ -201,6 +298,17 @@ func (e *environment) appURL(name, defaultValue string) *url.URL {
|
||||
return upstream
|
||||
}
|
||||
|
||||
// observe reads the setting that is the mode, enforce or observe, and
|
||||
// reports whether it is observe.
|
||||
func (e *environment) observe(name, defaultValue string) bool {
|
||||
mode := e.value(name, defaultValue)
|
||||
if mode != "enforce" && mode != "observe" {
|
||||
e.check(name, fmt.Errorf("%q %w", mode, errNotMode))
|
||||
}
|
||||
|
||||
return mode == "observe"
|
||||
}
|
||||
|
||||
// netblocks reads a setting that is a list of netblocks.
|
||||
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
|
||||
netblocks, err := parseNetblocks(e.value(name, defaultValue))
|
||||
@@ -225,6 +333,15 @@ func (e *environment) size(name, defaultValue string) int64 {
|
||||
return size
|
||||
}
|
||||
|
||||
// headerSize reads the setting that is the largest request line and
|
||||
// headers.
|
||||
func (e *environment) headerSize(name, defaultValue string) int64 {
|
||||
size, err := parseHeaderSize(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// count reads a setting that is a number of requests.
|
||||
func (e *environment) count(name, defaultValue string) int64 {
|
||||
count, err := parseCount(e.value(name, defaultValue))
|
||||
@@ -241,6 +358,79 @@ func (e *environment) countries(name, defaultValue string) []string {
|
||||
return countries
|
||||
}
|
||||
|
||||
// headerNames reads a setting that is a list of header names, and
|
||||
// returns them in lower case.
|
||||
func (e *environment) headerNames(name, defaultValue string) []string {
|
||||
headers, err := parseHeaderNames(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// durationNotOff reads a setting that is a duration and, unlike a
|
||||
// timeout, cannot be off.
|
||||
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
||||
duration, err := parseDurationNotOff(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// numberNotOff reads a setting that is a whole number above zero, which
|
||||
// cannot be off.
|
||||
func (e *environment) numberNotOff(name, defaultValue string) int {
|
||||
number, err := parseNumberNotOff(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
// banResponse reads a setting that is how a refused client is answered.
|
||||
func (e *environment) banResponse(name, defaultValue string) int {
|
||||
status, err := parseBanResponse(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// v4Prefix reads a setting that is the length of an IPv4 netblock.
|
||||
func (e *environment) v4Prefix(name, defaultValue string) int {
|
||||
length, err := parseV4Prefix(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
// absolutePath reads a setting that is an absolute path.
|
||||
func (e *environment) absolutePath(name, defaultValue string) string {
|
||||
path := e.value(name, defaultValue)
|
||||
if !filepath.IsAbs(path) {
|
||||
e.check(name, fmt.Errorf("%q %w", path, errNotAbsolutePath))
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// token reads a setting that is a bearer token. Unset, it is "", which
|
||||
// switches off what it guards; set, it must be at least minTokenLength
|
||||
// characters. Neither the log nor an error shows its value.
|
||||
func (e *environment) token(name string) string {
|
||||
value, set := e.lookupEnv(name)
|
||||
if !set {
|
||||
e.settings = append(e.settings, slog.String(name, ""))
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
e.settings = append(e.settings, slog.String(name, masked))
|
||||
|
||||
if utf8.RuneCountInString(value) < minTokenLength {
|
||||
e.check(name, errShortToken)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a
|
||||
// whole number of days such as 7d, or off.
|
||||
func parseDuration(value string) (time.Duration, error) {
|
||||
@@ -296,6 +486,19 @@ func parseSize(value string) (int64, error) {
|
||||
return n * unit, nil
|
||||
}
|
||||
|
||||
// parseHeaderSize reads the largest request line and headers: a size as
|
||||
// parseSize reads it, but more than 4K and never off. Go's server reads 4K
|
||||
// past the limit it is given before it refuses, so proxy.New gives it this
|
||||
// size less 4K, which must leave a limit.
|
||||
func parseHeaderSize(value string) (int64, error) {
|
||||
size, err := parseSize(value)
|
||||
if err != nil || size <= 4*kibibyte {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotOver4K)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// splitUnit splits a size into its number and the bytes its suffix
|
||||
// stands for.
|
||||
func splitUnit(value string) (string, int64) {
|
||||
@@ -329,6 +532,52 @@ func parseCount(value string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseDurationNotOff reads a duration above zero, as parseDuration does,
|
||||
// but not off.
|
||||
func parseDurationNotOff(value string) (time.Duration, error) {
|
||||
duration, err := parseDuration(value)
|
||||
if err != nil || duration == 0 {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// parseNumberNotOff reads a whole number above zero.
|
||||
func parseNumberNotOff(value string) (int, error) {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotNumberAboveZero)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseBanResponse reads how a refused client is answered: 403, 429, or
|
||||
// close, which is 0.
|
||||
func parseBanResponse(value string) (int, error) {
|
||||
switch value {
|
||||
case "403":
|
||||
return http.StatusForbidden, nil
|
||||
case "429":
|
||||
return http.StatusTooManyRequests, nil
|
||||
case "close":
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%q %w", value, errNotBanResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// parseV4Prefix reads the length of an IPv4 netblock, from 0 to 32.
|
||||
func parseV4Prefix(value string) (int, error) {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 0 || n > ipv4Bits {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotV4Prefix)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseList splits a comma-separated list and trims the spaces around
|
||||
// each item. An empty value is an empty list.
|
||||
func parseList(value string) ([]string, error) {
|
||||
@@ -444,6 +693,44 @@ func parseCountries(value string) ([]string, error) {
|
||||
return countries, nil
|
||||
}
|
||||
|
||||
// headerNameChars are the characters RFC 9110 allows in a header name:
|
||||
// letters, digits and these marks.
|
||||
const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
||||
"0123456789!#$%&'*+-.^_`|~"
|
||||
|
||||
// parseHeaderNames reads a comma-separated list of header names in either
|
||||
// case, and returns them in lower case. Host and Transfer-Encoding are
|
||||
// refused: Go's HTTP server takes them out of the request's headers.
|
||||
func parseHeaderNames(value string) ([]string, error) {
|
||||
items, err := parseList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make([]string, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
for _, char := range item {
|
||||
if !strings.ContainsRune(headerNameChars, char) {
|
||||
return nil, fmt.Errorf("%q %w", item, errNotHeaderName)
|
||||
}
|
||||
}
|
||||
|
||||
header := strings.ToLower(item)
|
||||
switch header {
|
||||
case "host":
|
||||
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
||||
item, errHeaderTakenOut)
|
||||
case "transfer-encoding":
|
||||
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
||||
}
|
||||
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// parseListenAddr checks an address to listen on: an optional host and a
|
||||
// port number.
|
||||
func parseListenAddr(value string) (string, error) {
|
||||
|
||||
+277
-28
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -18,8 +19,11 @@ import (
|
||||
const (
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
mode = "SWWAF_MODE"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
@@ -33,8 +37,28 @@ const (
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
)
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
|
||||
// token is a token of 32 characters, the shortest allowed.
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// off switches a timeout, a size limit or a rate limit off.
|
||||
const off = "off"
|
||||
|
||||
@@ -66,16 +90,30 @@ func TestDefaults(t *testing.T) {
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: ":8080",
|
||||
ClientRequestTimeout: time.Minute,
|
||||
ClientResponseTimeout: 30 * time.Minute,
|
||||
UpstreamRequestTimeout: time.Minute,
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 5 << 30,
|
||||
RateLimitPerMinute: 1000,
|
||||
RateLimitPerHour: 10000,
|
||||
RateLimitPerDay: 50000,
|
||||
ListenAddr: ":8080",
|
||||
Observe: false,
|
||||
ClientRequestTimeout: time.Minute,
|
||||
ClientRequestHeaderMaxBytes: 32 << 10,
|
||||
ClientIdleTimeout: 2 * time.Minute,
|
||||
ClientResponseTimeout: 30 * time.Minute,
|
||||
UpstreamRequestTimeout: time.Minute,
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 5 << 30,
|
||||
RateLimitPerMinute: 1000,
|
||||
RateLimitPerHour: 10000,
|
||||
RateLimitPerDay: 50000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
MaxBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
StateWriteDelay: 10 * time.Second,
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||
@@ -89,6 +127,18 @@ func TestDefaults(t *testing.T) {
|
||||
wantNetblocks(t, cfg.DenyNets)
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||
cfg.InstanceName, hostname, err)
|
||||
}
|
||||
|
||||
wantHeaders := strings.Split(defaultLogRequestHeaders, ",")
|
||||
if !slices.Equal(cfg.LogRequestHeaders, wantHeaders) {
|
||||
t.Errorf("%s gave %v, want %v", logRequestHeaders, cfg.LogRequestHeaders,
|
||||
wantHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValuesAsSet(t *testing.T) {
|
||||
@@ -97,8 +147,11 @@ func TestValuesAsSet(t *testing.T) {
|
||||
cfg := fromEnvironment(t, environment{
|
||||
listenAddr: "127.0.0.1:9000",
|
||||
upstreamURL: "https://app.internal:8443/",
|
||||
mode: "observe",
|
||||
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
|
||||
clientRequestTimeout: "90s",
|
||||
clientHeaderMaxBytes: "8K",
|
||||
clientIdleTimeout: "5m",
|
||||
clientResponseTimeout: "7d",
|
||||
upstreamRequestTimeout: "1h30m",
|
||||
upstreamResponseTimeout: off,
|
||||
@@ -112,19 +165,44 @@ func TestValuesAsSet(t *testing.T) {
|
||||
rateLimitPerDay: "6000",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
limitBanRepeatWindow: "2d",
|
||||
maxBanDuration: "30d",
|
||||
maxBans: "100",
|
||||
banScopeV4Prefix: "24",
|
||||
stateDir: "/srv/waf-state",
|
||||
stateWriteDelay: "500ms",
|
||||
stateCounterInterval: "1h",
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: "127.0.0.1:9000",
|
||||
ClientRequestTimeout: 90 * time.Second,
|
||||
ClientResponseTimeout: 7 * 24 * time.Hour,
|
||||
UpstreamRequestTimeout: 90 * time.Minute,
|
||||
UpstreamResponseTimeout: 0,
|
||||
RequestMaxBytes: 512 << 10,
|
||||
ResponseMaxBytes: 1234,
|
||||
RateLimitPerMinute: 60,
|
||||
RateLimitPerHour: 600,
|
||||
RateLimitPerDay: 6000,
|
||||
ListenAddr: "127.0.0.1:9000",
|
||||
Observe: true,
|
||||
ClientRequestTimeout: 90 * time.Second,
|
||||
ClientRequestHeaderMaxBytes: 8 << 10,
|
||||
ClientIdleTimeout: 5 * time.Minute,
|
||||
ClientResponseTimeout: 7 * 24 * time.Hour,
|
||||
UpstreamRequestTimeout: 90 * time.Minute,
|
||||
UpstreamResponseTimeout: 0,
|
||||
RequestMaxBytes: 512 << 10,
|
||||
ResponseMaxBytes: 1234,
|
||||
RateLimitPerMinute: 60,
|
||||
RateLimitPerHour: 600,
|
||||
RateLimitPerDay: 6000,
|
||||
BanResponse: 429,
|
||||
LimitBanDuration: 15 * time.Minute,
|
||||
LimitBanRepeatWindow: 48 * time.Hour,
|
||||
MaxBanDuration: 30 * 24 * time.Hour,
|
||||
MaxBans: 100,
|
||||
BanScopeV4Prefix: 24,
|
||||
StateDir: "/srv/waf-state",
|
||||
StateWriteDelay: 500 * time.Millisecond,
|
||||
StateCounterInterval: time.Hour,
|
||||
MetricsToken: token,
|
||||
MetricsTopN: 10,
|
||||
})
|
||||
|
||||
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
|
||||
@@ -139,6 +217,21 @@ func TestValuesAsSet(t *testing.T) {
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||
}
|
||||
|
||||
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
instanceName: "fsn1app1/gitea",
|
||||
logRequestHeaders: " Accept , X-Custom",
|
||||
})
|
||||
|
||||
if cfg.InstanceName != "fsn1app1/gitea" ||
|
||||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
||||
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
||||
logRequestHeaders, cfg.LogRequestHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -163,12 +256,42 @@ func TestSizesAndOff(t *testing.T) {
|
||||
requestMaxBytes: "3G",
|
||||
responseMaxBytes: off,
|
||||
clientRequestTimeout: off,
|
||||
clientIdleTimeout: off,
|
||||
})
|
||||
|
||||
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 ||
|
||||
cfg.ClientRequestTimeout != 0 {
|
||||
t.Errorf("3G, off and off read as %d, %d and %s",
|
||||
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout)
|
||||
cfg.ClientRequestTimeout != 0 || cfg.ClientIdleTimeout != 0 {
|
||||
t.Errorf("3G, off, off and off read as %d, %d, %s and %s",
|
||||
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout,
|
||||
cfg.ClientIdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
|
||||
if cfg.ClientRequestHeaderMaxBytes != 4097 {
|
||||
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"32KB", "0", "4K", off} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(
|
||||
environment{clientHeaderMaxBytes: value}.lookupEnv)
|
||||
|
||||
want := clientHeaderMaxBytes + `: "` + value +
|
||||
`" is not a size of more than 4K, such as 32K`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +311,15 @@ func TestRateLimitsOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanResponseCloseIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{banResponse: "close"})
|
||||
if cfg.BanResponse != 0 {
|
||||
t.Errorf("close read as %d, want 0", cfg.BanResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -201,9 +333,7 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct{ name, value string }{
|
||||
{listenAddr, "8080"},
|
||||
{listenAddr, ":http"},
|
||||
{listenAddr, ":65536"},
|
||||
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
|
||||
{upstreamURL, "127.0.0.1:8081"},
|
||||
{upstreamURL, "ftp://127.0.0.1:8081"},
|
||||
{upstreamURL, "http://"},
|
||||
@@ -213,6 +343,7 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{upstreamURL, "http://127.0.0.1:8081/app"},
|
||||
{upstreamURL, "http://127.0.0.1:8081/?a=1"},
|
||||
{upstreamURL, "http://user:secret@127.0.0.1:8081"},
|
||||
{mode, "Observe"}, {mode, "block"}, {mode, ""},
|
||||
{trustedProxies, "10.0.0.0/33"},
|
||||
{trustedProxies, "traefik"},
|
||||
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
|
||||
@@ -220,8 +351,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{allowNets, "192.0.2.0/24,monitoring"},
|
||||
{rateLimitExemptNets, "2001:db8::/129"},
|
||||
{denyNets, "198.51.100.0/24,"},
|
||||
{clientRequestTimeout, "60"},
|
||||
{clientRequestTimeout, ""},
|
||||
{clientRequestTimeout, "60"}, {clientRequestTimeout, ""},
|
||||
{clientIdleTimeout, "0s"},
|
||||
{clientIdleTimeout, "2 minutes"},
|
||||
{clientResponseTimeout, "1y"},
|
||||
{upstreamRequestTimeout, "-1s"},
|
||||
{upstreamResponseTimeout, "0s"},
|
||||
@@ -250,6 +382,20 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{allowedCountries, "uk"},
|
||||
{allowedCountries, "zz"},
|
||||
{allowedCountries, "de,germany"},
|
||||
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
|
||||
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
|
||||
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
|
||||
{maxBanDuration, off}, {maxBanDuration, "1w"},
|
||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
|
||||
{logRequestHeaders, "accept,,origin"}, {logRequestHeaders, "accept;origin"},
|
||||
{logRequestHeaders, "accept language"}, {logRequestHeaders, "x-foo:"},
|
||||
{logRequestHeaders, "host"}, {logRequestHeaders, "accept,Host"},
|
||||
{logRequestHeaders, "transfer-encoding"}, {logRequestHeaders, "TRANSFER-ENCODING"},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -266,6 +412,60 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Only Host's message points to the field host.
|
||||
for value, want := range map[string]string{
|
||||
"Host": `"Host" is taken out of every request by Go's HTTP server, ` +
|
||||
"so it can never be logged; the request's host is the field host",
|
||||
"transfer-encoding": `"transfer-encoding" is taken out of every ` +
|
||||
"request by Go's HTTP server, so it can never be logged",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
|
||||
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
||||
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Characters are counted, not bytes: each é takes two.
|
||||
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{metricsToken: value}.lookupEnv)
|
||||
|
||||
want := metricsToken + ": is shorter than 32 characters"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsLoggedMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{metricsToken: token})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
||||
|
||||
if strings.Contains(out.String(), token) ||
|
||||
!strings.Contains(out.String(), `"`+metricsToken+`":"********"`) {
|
||||
t.Errorf("the token is not logged masked: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -284,11 +484,16 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
t.Fatalf("decode %s: %v", out.Bytes(), err)
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
want := map[string]string{
|
||||
listenAddr: ":8080",
|
||||
upstreamURL: "http://127.0.0.1:8081",
|
||||
mode: "enforce",
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||
clientRequestTimeout: "45s",
|
||||
clientHeaderMaxBytes: "32K",
|
||||
clientIdleTimeout: "120s",
|
||||
clientResponseTimeout: "30m",
|
||||
upstreamRequestTimeout: "60s",
|
||||
upstreamResponseTimeout: "30m",
|
||||
@@ -302,6 +507,19 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
rateLimitPerDay: "50000",
|
||||
deniedCountries: "",
|
||||
allowedCountries: "",
|
||||
banResponse: "403",
|
||||
limitBanDuration: "1h",
|
||||
limitBanRepeatWindow: "24h",
|
||||
maxBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
metricsToken: "",
|
||||
metricsTopN: "50",
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
}
|
||||
if !maps.Equal(line.Settings, want) {
|
||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||
@@ -313,7 +531,10 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
t.Helper()
|
||||
|
||||
if got.ListenAddr != want.ListenAddr ||
|
||||
got.Observe != want.Observe ||
|
||||
got.ClientRequestTimeout != want.ClientRequestTimeout ||
|
||||
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
|
||||
got.ClientIdleTimeout != want.ClientIdleTimeout ||
|
||||
got.ClientResponseTimeout != want.ClientResponseTimeout ||
|
||||
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
|
||||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
|
||||
@@ -324,6 +545,34 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
got.RateLimitPerDay != want.RateLimitPerDay {
|
||||
t.Errorf("settings\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantBanSettings(t, got, want)
|
||||
}
|
||||
|
||||
// wantBanSettings checks the settings for bans, the state files and the
|
||||
// metrics.
|
||||
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||
t.Helper()
|
||||
|
||||
if got.BanResponse != want.BanResponse ||
|
||||
got.LimitBanDuration != want.LimitBanDuration ||
|
||||
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
|
||||
got.MaxBanDuration != want.MaxBanDuration ||
|
||||
got.MaxBans != want.MaxBans ||
|
||||
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
|
||||
t.Errorf("ban settings\n%+v\nwant\n%+v", 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 {
|
||||
t.Errorf("metrics token %q and top %d, want %q and %d",
|
||||
got.MetricsToken, got.MetricsTopN, want.MetricsToken, want.MetricsTopN)
|
||||
}
|
||||
}
|
||||
|
||||
// wantNetblocks checks a list of netblocks.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package lookup
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SetTransport has g's requests to GeoJS go through transport instead of
|
||||
// the network.
|
||||
func (g *GeoJS) SetTransport(transport http.RoundTripper) {
|
||||
g.httpClient.Transport = transport
|
||||
}
|
||||
+84
-13
@@ -1,6 +1,7 @@
|
||||
// Package lookup looks up each client's country through the GeoJS web
|
||||
// service, and keeps the answers in memory, for at most 100,000 clients
|
||||
// and for 7 days each.
|
||||
// and for 7 days each. The answers are written to lookups.json and read
|
||||
// from it by the state package.
|
||||
package lookup
|
||||
|
||||
import (
|
||||
@@ -12,11 +13,13 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
)
|
||||
|
||||
// URL is GeoJS's country endpoint. Asked about several addresses at once,
|
||||
@@ -62,6 +65,9 @@ type Params struct {
|
||||
Now func() time.Time
|
||||
// ProcessLog receives GeoJS's failures.
|
||||
ProcessLog *slog.Logger
|
||||
// Metrics count the requests to GeoJS, those that failed, and the
|
||||
// clients that go without an answer.
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// GeoJS looks up clients' countries through GeoJS. At most one request
|
||||
@@ -71,12 +77,13 @@ type GeoJS struct {
|
||||
url string
|
||||
now func() time.Time
|
||||
processLog *slog.Logger
|
||||
metrics *metrics.Metrics
|
||||
// httpClient follows no redirect, so that visitors' addresses go to
|
||||
// GeoJS alone: a redirect is a failure.
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
answers *simplelru.LRU[netip.Prefix, answer]
|
||||
answers *simplelru.LRU[netip.Prefix, *Answer]
|
||||
// waiting are the clients without an answer: those to ask GeoJS about,
|
||||
// and those it is being asked about.
|
||||
waiting map[netip.Prefix]*wait
|
||||
@@ -88,11 +95,14 @@ type GeoJS struct {
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
// answer is what GeoJS said about a client: its country, "" when GeoJS
|
||||
// cannot place it, and when GeoJS said so.
|
||||
type answer struct {
|
||||
country string
|
||||
received time.Time
|
||||
// Answer is what GeoJS said about a client, as lookups.json holds it: its
|
||||
// country, "" when GeoJS cannot place it, when GeoJS said so, and when
|
||||
// the answer was last used.
|
||||
type Answer struct {
|
||||
Client netip.Prefix `json:"client"`
|
||||
Country string `json:"country"`
|
||||
Answered time.Time `json:"answered"`
|
||||
Used time.Time `json:"used"`
|
||||
}
|
||||
|
||||
// wait is a client waiting for its answer.
|
||||
@@ -107,7 +117,7 @@ type wait struct {
|
||||
|
||||
// New returns a GeoJS with no answer kept yet.
|
||||
func New(params Params) *GeoJS {
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, *Answer](maxAnswers, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
@@ -116,6 +126,7 @@ func New(params Params) *GeoJS {
|
||||
url: params.URL,
|
||||
now: params.Now,
|
||||
processLog: params.ProcessLog,
|
||||
metrics: params.Metrics,
|
||||
httpClient: &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -155,6 +166,9 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
||||
defer g.mu.Unlock()
|
||||
|
||||
country, found := g.kept(client)
|
||||
if !found {
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
}
|
||||
|
||||
w, waiting := g.waiting[client]
|
||||
if !found && waiting {
|
||||
@@ -164,6 +178,49 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
||||
return country
|
||||
}
|
||||
|
||||
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
||||
// lists them.
|
||||
func (g *GeoJS) Snapshot() []Answer {
|
||||
g.mu.Lock()
|
||||
|
||||
answers := make([]Answer, 0, g.answers.Len())
|
||||
for _, kept := range g.answers.Values() {
|
||||
answers = append(answers, *kept)
|
||||
}
|
||||
|
||||
g.mu.Unlock()
|
||||
|
||||
slices.SortFunc(answers, func(a, b Answer) int {
|
||||
return a.Client.Compare(b.Client)
|
||||
})
|
||||
|
||||
return answers
|
||||
}
|
||||
|
||||
// Load keeps answers read from lookups.json, in place of the answers it
|
||||
// keeps, in the order they were last used, so that the one used longest
|
||||
// ago is dropped first. Answers GeoJS gave keepFor ago or more are
|
||||
// dropped.
|
||||
func (g *GeoJS) Load(answers []Answer) {
|
||||
answers = slices.Clone(answers)
|
||||
slices.SortStableFunc(answers, func(a, b Answer) int {
|
||||
return a.Used.Compare(b.Used)
|
||||
})
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.answers.Purge()
|
||||
|
||||
now := g.now()
|
||||
|
||||
for _, answer := range answers {
|
||||
if now.Sub(answer.Answered) < keepFor {
|
||||
g.answers.Add(answer.Client, &answer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answerOrWait returns client's kept answer if it has one. Otherwise it
|
||||
// puts the client among those waiting if there is room, has GeoJS asked
|
||||
// about them if it can be, and returns what to wait on for the answer, or
|
||||
@@ -188,6 +245,8 @@ func (g *GeoJS) answerOrWait(
|
||||
g.ask(ctx)
|
||||
|
||||
if w == nil {
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return "", nil // too many clients wait already
|
||||
}
|
||||
|
||||
@@ -197,21 +256,27 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w.late {
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", w.asked
|
||||
}
|
||||
|
||||
// kept returns client's answer, if one was received less than keepFor
|
||||
// ago.
|
||||
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
||||
// and notes that it was used.
|
||||
func (g *GeoJS) kept(client netip.Prefix) (string, bool) {
|
||||
now := g.now()
|
||||
|
||||
kept, found := g.answers.Get(client)
|
||||
if !found || g.now().Sub(kept.received) >= keepFor {
|
||||
if !found || now.Sub(kept.Answered) >= keepFor {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return kept.country, true
|
||||
kept.Used = now
|
||||
|
||||
return kept.Country, true
|
||||
}
|
||||
|
||||
// ask starts asking GeoJS about the waiting clients, unless a request to
|
||||
@@ -293,7 +358,9 @@ func (g *GeoJS) keep(
|
||||
continue
|
||||
}
|
||||
|
||||
g.answers.Add(client, answer{country: country, received: now})
|
||||
g.answers.Add(client, &Answer{
|
||||
Client: client, Country: country, Answered: now, Used: now,
|
||||
})
|
||||
close(g.waiting[client].asked)
|
||||
delete(g.waiting, client)
|
||||
}
|
||||
@@ -303,6 +370,8 @@ func (g *GeoJS) keep(
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
g.metrics.GeoJSFailures.Inc()
|
||||
|
||||
g.retryDelay = min(max(retryDelayFactor*g.retryDelay, firstRetryDelay),
|
||||
maxRetryDelay)
|
||||
g.retryAt = now.Add(g.retryDelay)
|
||||
@@ -347,6 +416,8 @@ func (g *GeoJS) request(
|
||||
|
||||
req.URL.RawQuery = "ip=" + strings.Join(addrs, ",")
|
||||
|
||||
g.metrics.GeoJSRequests.Inc()
|
||||
|
||||
res, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Do's error names the URL, and so the visitors' addresses, which
|
||||
|
||||
+303
-225
@@ -10,9 +10,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,80 +29,87 @@ const (
|
||||
leftOut = "203.0.113.7"
|
||||
// timeout is how long a new client waits for its answer.
|
||||
timeout = time.Second
|
||||
// waitLimit bounds how long a test waits for what should happen.
|
||||
waitLimit = 10 * time.Second
|
||||
// pollInterval is how often a test looks again.
|
||||
pollInterval = 10 * time.Millisecond
|
||||
// week is how long an answer is kept.
|
||||
week = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// The tests that have GeoJS asked run in a synctest bubble, where the time
|
||||
// package runs on a clock of the test's own: a wait lasts exactly as long
|
||||
// as it should, however slowly the test process runs, and synctest.Wait
|
||||
// returns once g has done all it can before time passes. The stand-in for
|
||||
// GeoJS answers without the network, since a request waiting on the
|
||||
// network would keep that clock from moving on.
|
||||
|
||||
func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
|
||||
// An answer without a country is kept too.
|
||||
clock.advance(week - time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
// An answer without a country is kept too.
|
||||
clock.advance(week - time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
wantAsked(t, geojs, 2, "203.0.113.9")
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
wantAsked(t, geojs, 2, "203.0.113.9")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// The client comes while GeoJS is asked about an earlier client, which
|
||||
// it answers most of a second later. It is then asked about the client
|
||||
// and does not answer: that request is abandoned a second after it
|
||||
// began, well after the client's wait is over.
|
||||
geojs.set(answeringSlowly)
|
||||
// The client comes while GeoJS is asked about an earlier client, which
|
||||
// it answers most of a second later. It is then asked about the client
|
||||
// and does not answer: that request is abandoned a second after it
|
||||
// began, well after the client's wait is over.
|
||||
geojs.set(answeringSlowly)
|
||||
|
||||
var earlier sync.WaitGroup
|
||||
var earlier sync.WaitGroup
|
||||
|
||||
earlier.Go(func() { g.Country(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||
defer earlier.Wait()
|
||||
earlier.Go(func() { g.Country(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||
defer earlier.Wait()
|
||||
|
||||
waitForRequests(t, geojs, 1)
|
||||
geojs.set(hanging)
|
||||
waitForRequests(t, geojs, 1)
|
||||
geojs.set(hanging)
|
||||
|
||||
began := time.Now()
|
||||
began := time.Now()
|
||||
|
||||
wantCountry(t, g, client, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
took := time.Since(began)
|
||||
if took < timeout || took > timeout+timeout/2 {
|
||||
t.Errorf("waited %s for the answer, want %s", took, timeout)
|
||||
}
|
||||
took := time.Since(began)
|
||||
if took != timeout {
|
||||
t.Errorf("waited %s for the answer, want %s", took, timeout)
|
||||
}
|
||||
|
||||
// Its next request does not wait.
|
||||
began = time.Now()
|
||||
// Its next request does not wait.
|
||||
began = time.Now()
|
||||
|
||||
wantCountry(t, g, client, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
took = time.Since(began)
|
||||
if took > timeout/2 {
|
||||
t.Errorf("waited %s again, want no wait", took)
|
||||
}
|
||||
took = time.Since(began)
|
||||
if took != 0 {
|
||||
t.Errorf("waited %s again, want no wait", took)
|
||||
}
|
||||
|
||||
// Once GeoJS answers, the client is asked about again in the
|
||||
// background, and has its country.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
// Once GeoJS answers, the client is asked about again in the
|
||||
// background, and has its country.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
@@ -118,33 +128,35 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
other := netip.MustParsePrefix("203.0.113.1/32")
|
||||
client := netip.MustParsePrefix(leftOut + "/32")
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
other := netip.MustParsePrefix("203.0.113.1/32")
|
||||
client := netip.MustParsePrefix(leftOut + "/32")
|
||||
|
||||
// GeoJS fails, and is left alone for a second while the client
|
||||
// comes too, so that the next request asks about both.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, other, "")
|
||||
wantCountry(t, g, client, "")
|
||||
// GeoJS fails, and is left alone for a second while the client
|
||||
// comes too, so that the next request asks about both.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, other, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
geojs.set(tc.answers)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, other, "")
|
||||
waitForRequests(t, geojs, 2)
|
||||
geojs.set(tc.answers)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, other, "")
|
||||
waitForRequests(t, geojs, 2)
|
||||
|
||||
// The answer counts as a failure, and the client is asked about
|
||||
// again, with the other client only if the answer left it out too.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
wantCountry(t, g, other, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
// The answer counts as a failure, and the client is asked about
|
||||
// again, with the other client only if the answer left it out too.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
wantCountry(t, g, other, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
|
||||
if tc.named {
|
||||
wantAsked(t, geojs, 2, leftOut)
|
||||
} else {
|
||||
wantAsked(t, geojs, 2, leftOut, "203.0.113.1")
|
||||
}
|
||||
if tc.named {
|
||||
wantAsked(t, geojs, 2, leftOut)
|
||||
} else {
|
||||
wantAsked(t, geojs, 2, leftOut, "203.0.113.1")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -152,173 +164,225 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
func TestRedirectCountsAsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, _, g := start(t)
|
||||
geojs.set(redirecting)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, _, g := start()
|
||||
geojs.set(redirecting)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCountryIsKeptInCapitals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, _, g := start(t)
|
||||
geojs.set(answeringInLowerCase)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, _, g := start()
|
||||
geojs.set(answeringInLowerCase)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var log strings.Builder
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var log strings.Builder
|
||||
|
||||
// Nothing listens on port 1, so asking GeoJS fails.
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: "http://127.0.0.1:1",
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||
// GeoJS does not answer, so the request to it is abandoned, and fails.
|
||||
geojs := &standIn{answers: hanging}
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||
Metrics: metrics.New(1),
|
||||
})
|
||||
g.SetTransport(geojs)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
synctest.Wait()
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged, "asking GeoJS failed") ||
|
||||
strings.Contains(logged, "203.0.113.9") {
|
||||
t.Errorf("logged %q, want the failure without the address asked about", logged)
|
||||
}
|
||||
})
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged, "asking GeoJS failed") ||
|
||||
strings.Contains(logged, "203.0.113.9") {
|
||||
t.Errorf("logged %q, want the failure without the address asked about", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
|
||||
// GeoJS fails, and is then left alone for a second, while three more
|
||||
// clients come. An IPv6 client is a /64, and GeoJS is asked about its
|
||||
// first address.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.1/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.2/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("2001:db8:1:2::/64"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
// GeoJS fails, and is then left alone for a second, while three more
|
||||
// clients come. An IPv6 client is a /64, and GeoJS is asked about its
|
||||
// first address.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.1/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.2/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("2001:db8:1:2::/64"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
|
||||
wantRequests(t, geojs, 2)
|
||||
wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
|
||||
wantRequests(t, geojs, 2)
|
||||
wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
kept := clients()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
kept := clients()
|
||||
|
||||
wantCountry(t, g, kept, germany)
|
||||
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, 1)
|
||||
|
||||
// Each failure leaves GeoJS alone twice as long as the one before, up
|
||||
// to five minutes. New clients meanwhile count as not found, and the
|
||||
// client with a kept answer still gets its country, without GeoJS being
|
||||
// asked.
|
||||
requests := 1
|
||||
|
||||
for _, delay := range []time.Duration{
|
||||
time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second,
|
||||
16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second,
|
||||
256 * time.Second, 5 * time.Minute, 5 * time.Minute,
|
||||
} {
|
||||
wantCountry(t, g, clients(), "")
|
||||
|
||||
requests++
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(delay - time.Millisecond)
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(time.Millisecond)
|
||||
}
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, 1)
|
||||
|
||||
// Once GeoJS answers again, it is asked about every client waiting.
|
||||
geojs.set(answering)
|
||||
wantCountry(t, g, clients(), germany)
|
||||
wantRequests(t, geojs, requests+1)
|
||||
// Each failure leaves GeoJS alone twice as long as the one before, up
|
||||
// to five minutes. New clients meanwhile count as not found, and the
|
||||
// client with a kept answer still gets its country, without GeoJS being
|
||||
// asked.
|
||||
requests := 1
|
||||
|
||||
asked := waitForRequests(t, geojs, requests+1)
|
||||
if len(asked[requests]) != 23 {
|
||||
t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
|
||||
}
|
||||
for _, delay := range []time.Duration{
|
||||
time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second,
|
||||
16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second,
|
||||
256 * time.Second, 5 * time.Minute, 5 * time.Minute,
|
||||
} {
|
||||
wantCountry(t, g, clients(), "")
|
||||
|
||||
requests++
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(delay - time.Millisecond)
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(time.Millisecond)
|
||||
}
|
||||
|
||||
// Once GeoJS answers again, it is asked about every client waiting.
|
||||
geojs.set(answering)
|
||||
wantCountry(t, g, clients(), germany)
|
||||
wantRequests(t, geojs, requests+1)
|
||||
|
||||
asked := waitForRequests(t, geojs, requests+1)
|
||||
if len(asked[requests]) != 23 {
|
||||
t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAtMost200AddressesInOneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
|
||||
// 201 clients wait while GeoJS is left alone after a failure.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
// 201 clients wait while GeoJS is left alone after a failure.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
for range 200 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
for range 200 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
// The first one's next request has GeoJS asked again.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
// The first one's next request has GeoJS asked again.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 3)
|
||||
if len(asked[1]) != 200 || len(asked[2]) != 1 {
|
||||
t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
|
||||
len(asked[1]), len(asked[2]))
|
||||
}
|
||||
asked := waitForRequests(t, geojs, 3)
|
||||
if len(asked[1]) != 200 || len(asked[2]) != 1 {
|
||||
t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
|
||||
len(asked[1]), len(asked[2]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAtMost10000ClientsWait(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
|
||||
// 10,000 clients wait while GeoJS is left alone after a failure, and
|
||||
// one more cannot join them.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
// 10,000 clients wait while GeoJS is left alone after a failure, and
|
||||
// one more cannot join them.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
extra := clients()
|
||||
wantCountry(t, g, extra, "")
|
||||
|
||||
// The first one's next request has GeoJS asked about the 10,000, 200
|
||||
// at a time, and not about the one more.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 51)
|
||||
for i, request := range asked {
|
||||
if slices.Contains(request, extra.Addr().String()) {
|
||||
t.Errorf("request %d asked about %s", i, extra.Addr())
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
}
|
||||
|
||||
// With room among those waiting, it is asked about.
|
||||
wantCountry(t, g, extra, germany)
|
||||
extra := clients()
|
||||
wantCountry(t, g, extra, "")
|
||||
|
||||
// The first one's next request has GeoJS asked about the 10,000, 200
|
||||
// at a time, and not about the one more.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 51)
|
||||
for i, request := range asked {
|
||||
if slices.Contains(request, extra.Addr().String()) {
|
||||
t.Errorf("request %d asked about %s", i, extra.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
// With room among those waiting, it is asked about.
|
||||
wantCountry(t, g, extra, germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
m := metrics.New(1)
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: m,
|
||||
})
|
||||
g.SetTransport(&standIn{answers: failing})
|
||||
|
||||
clients := newClients()
|
||||
|
||||
// GeoJS fails, so the first client goes without an answer, and GeoJS
|
||||
// is left alone for a second, which does not pass in this test.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantUnanswered(t, m, 1)
|
||||
|
||||
// Meanwhile each new client goes without one at once, while there is
|
||||
// room for it among the 10,000 that may wait.
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
wantUnanswered(t, m, 10000)
|
||||
|
||||
// One more, for which there is no room, goes without one too.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantUnanswered(t, m, 10001)
|
||||
})
|
||||
}
|
||||
|
||||
// How the stand-in for GeoJS answers.
|
||||
@@ -337,13 +401,25 @@ const (
|
||||
// standIn is a stand-in for GeoJS. It notes the addresses each request
|
||||
// asks about.
|
||||
type standIn struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
answers int
|
||||
requests [][]string
|
||||
}
|
||||
|
||||
// RoundTrip has the stand-in answer req, in place of the network. A request
|
||||
// abandoned before the stand-in answers fails, as over the network.
|
||||
func (s *standIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
answer := httptest.NewRecorder()
|
||||
s.ServeHTTP(answer, req)
|
||||
|
||||
err := req.Context().Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answer.Result(), nil
|
||||
}
|
||||
|
||||
// ServeHTTP answers a request about the addresses in its ip parameter.
|
||||
func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
addrs := strings.Split(r.URL.Query().Get("ip"), ",")
|
||||
@@ -422,7 +498,8 @@ func (s *standIn) asked() [][]string {
|
||||
return slices.Clone(s.requests)
|
||||
}
|
||||
|
||||
// testClock is a clock the test sets.
|
||||
// testClock is a clock the test sets. GeoJS tells the time by it, while
|
||||
// waits run on the bubble's clock.
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
@@ -444,21 +521,18 @@ func (c *testClock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// start starts a stand-in for GeoJS that answers, and returns it, a
|
||||
// clock, and a GeoJS asking it by that clock.
|
||||
func start(t *testing.T) (*standIn, *testClock, *lookup.GeoJS) {
|
||||
t.Helper()
|
||||
|
||||
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
||||
// asking the stand-in by that clock.
|
||||
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
||||
geojs := &standIn{}
|
||||
geojs.server = httptest.NewServer(geojs)
|
||||
t.Cleanup(geojs.server.Close)
|
||||
|
||||
clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: geojs.server.URL,
|
||||
URL: lookup.URL,
|
||||
Now: clock.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: metrics.New(1),
|
||||
})
|
||||
g.SetTransport(geojs)
|
||||
|
||||
return geojs, clock, g
|
||||
}
|
||||
@@ -513,41 +587,45 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRequests waits for GeoJS to have had count requests, and returns
|
||||
// the addresses each asked about.
|
||||
// wantUnanswered checks how many requests m counts as having gone without
|
||||
// an answer from GeoJS.
|
||||
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := testutil.ToFloat64(m.GeoJSUnanswered)
|
||||
if got != want {
|
||||
t.Errorf("%v requests went without an answer, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRequests waits until g has done all it can before time passes,
|
||||
// checks that GeoJS has had count requests, and returns the addresses each
|
||||
// asked about.
|
||||
func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
asked := geojs.asked()
|
||||
if len(asked) >= count {
|
||||
return asked
|
||||
}
|
||||
synctest.Wait()
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
asked := geojs.asked()
|
||||
if len(asked) != count {
|
||||
t.Fatalf("GeoJS had %d requests, want %d", len(asked), count)
|
||||
}
|
||||
|
||||
t.Fatalf("fewer than %d requests to GeoJS after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
return asked
|
||||
}
|
||||
|
||||
// waitForCountry waits for g to give client the country want, moving the
|
||||
// clock on a minute at a time, so that GeoJS is asked again after a
|
||||
// failure.
|
||||
// waitForCountry lets a request to GeoJS under way be abandoned, and moves
|
||||
// the clock on a minute, so that GeoJS may be asked again after a failure.
|
||||
// It then checks that client's next request does not wait but has it asked
|
||||
// about again in the background, after which g gives it the country want.
|
||||
func waitForCountry(
|
||||
t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for g.Country(t.Context(), client) != want {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s is not in %q after %s", client, want, waitLimit)
|
||||
}
|
||||
|
||||
clock.advance(time.Minute)
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
time.Sleep(timeout)
|
||||
clock.advance(time.Minute)
|
||||
wantCountry(t, g, client, "")
|
||||
synctest.Wait()
|
||||
wantCountry(t, g, client, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package lookup_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
)
|
||||
|
||||
func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
_, clock, g := start()
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
asked := clock.Now()
|
||||
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
|
||||
clock.advance(time.Hour)
|
||||
wantCountry(t, g, placed, germany)
|
||||
|
||||
want := []lookup.Answer{
|
||||
{Client: notPlaced, Country: "", Answered: asked, Used: asked},
|
||||
{Client: placed, Country: germany, Answered: asked, Used: asked.Add(time.Hour)},
|
||||
}
|
||||
if got := g.Snapshot(); !slices.Equal(got, want) {
|
||||
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadedAnswersAreKeptFor7DaysFromWhenGeoJSGaveThem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs, clock, g := start()
|
||||
now := clock.Now()
|
||||
kept := lookup.Answer{
|
||||
Client: netip.MustParsePrefix("203.0.113.9/32"),
|
||||
Country: "FR",
|
||||
Answered: now.Add(-week + time.Second),
|
||||
Used: now.Add(-time.Hour),
|
||||
}
|
||||
stale := lookup.Answer{
|
||||
Client: netip.MustParsePrefix("203.0.113.10/32"),
|
||||
Country: "FR",
|
||||
Answered: now.Add(-week),
|
||||
Used: now.Add(-time.Hour),
|
||||
}
|
||||
|
||||
g.Load([]lookup.Answer{kept, stale})
|
||||
|
||||
if got := g.Snapshot(); !slices.Equal(got, []lookup.Answer{kept}) {
|
||||
t.Errorf("kept %+v, want only the answer GeoJS gave less than 7 days ago", got)
|
||||
}
|
||||
|
||||
wantCountry(t, g, kept.Client, "FR")
|
||||
wantRequests(t, geojs, 0)
|
||||
}
|
||||
|
||||
func TestLoadDropsTheAnswerUsedLongestAgoFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxAnswers = 100000
|
||||
|
||||
_, clock, g := start()
|
||||
now := clock.Now()
|
||||
|
||||
// lookups.json lists the answers by client. Here each was last used a
|
||||
// second before the one listed before it, so the last listed is the
|
||||
// one used longest ago, and the one dropped.
|
||||
answers := make([]lookup.Answer, maxAnswers+1)
|
||||
addr := netip.MustParseAddr("10.0.0.0")
|
||||
|
||||
for i := range answers {
|
||||
answers[i] = lookup.Answer{
|
||||
Client: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Country: germany,
|
||||
Answered: now,
|
||||
Used: now.Add(-time.Duration(i) * time.Second),
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
|
||||
g.Load(answers)
|
||||
|
||||
got := g.Snapshot()
|
||||
if len(got) != maxAnswers || got[0] != answers[0] ||
|
||||
got[maxAnswers-1] != answers[maxAnswers-1] {
|
||||
t.Errorf("%d answers kept, from %s to %s; want %d, from %s to %s",
|
||||
len(got), got[0].Client, got[len(got)-1].Client, maxAnswers,
|
||||
answers[0].Client, answers[maxAnswers-1].Client)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// other is the label under which the countries outside the busiest are
|
||||
// counted.
|
||||
const other = "other"
|
||||
|
||||
// countries are the metrics by the client's country, for requests whose
|
||||
// client's country is known. The topN busiest countries, by their requests
|
||||
// since the start, have series of their own, and the others are counted
|
||||
// under other, so that there are never more than topN + 1 series. A
|
||||
// country that drops out of the busiest loses its series, and its next
|
||||
// requests are counted under other; one that becomes one of them gets a
|
||||
// series that counts from then on. Each series therefore only ever goes
|
||||
// up.
|
||||
type countries struct {
|
||||
topN int
|
||||
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
// refused are the requests the country lists refused.
|
||||
refused *prometheus.CounterVec
|
||||
|
||||
mu sync.Mutex
|
||||
// seen is each country's requests since the start, by which the
|
||||
// countries are ranked. GeoJS gives two-letter codes, so it holds at
|
||||
// most a few hundred.
|
||||
seen map[string]int64
|
||||
// top are the countries with series of their own.
|
||||
top map[string]bool
|
||||
}
|
||||
|
||||
// newCountries returns the metrics by country, with series of their own
|
||||
// for the topN busiest countries.
|
||||
func newCountries(topN int) *countries {
|
||||
byCountry := []string{"country"}
|
||||
|
||||
return &countries{
|
||||
topN: topN,
|
||||
requests: counterVec("smallwebwaf_country_requests_total",
|
||||
"Requests, by the client's country.", byCountry),
|
||||
requestBytes: counterVec("smallwebwaf_country_request_bytes_total",
|
||||
"Request body bytes, by the client's country.", byCountry),
|
||||
responseBytes: counterVec("smallwebwaf_country_response_bytes_total",
|
||||
"Response body bytes, by the client's country.", byCountry),
|
||||
refused: counterVec("smallwebwaf_country_list_refusals_total",
|
||||
"Requests the country lists refused, by the client's country.",
|
||||
byCountry),
|
||||
seen: map[string]int64{},
|
||||
top: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// add counts a request from its log line, whose country is known.
|
||||
func (c *countries) add(line *requestlog.Line) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.seen[line.Country]++
|
||||
|
||||
label := c.label(line.Country)
|
||||
c.requests.WithLabelValues(label).Inc()
|
||||
c.requestBytes.WithLabelValues(label).Add(float64(line.RequestBytes))
|
||||
c.responseBytes.WithLabelValues(label).Add(float64(line.ResponseBytes))
|
||||
|
||||
if line.Action == requestlog.ActionCountryDenied {
|
||||
c.refused.WithLabelValues(label).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// label returns the label a request from country is counted under: the
|
||||
// country while it is one of the busiest, other while it is not. A
|
||||
// country busier than the least busy of them takes its place, and that
|
||||
// country's series are dropped.
|
||||
func (c *countries) label(country string) string {
|
||||
if c.top[country] {
|
||||
return country
|
||||
}
|
||||
|
||||
if len(c.top) < c.topN {
|
||||
c.top[country] = true
|
||||
|
||||
return country
|
||||
}
|
||||
|
||||
least := ""
|
||||
|
||||
for top := range c.top {
|
||||
if least == "" || c.seen[top] < c.seen[least] {
|
||||
least = top
|
||||
}
|
||||
}
|
||||
|
||||
if c.seen[country] <= c.seen[least] {
|
||||
return other
|
||||
}
|
||||
|
||||
delete(c.top, least)
|
||||
|
||||
for _, vec := range []*prometheus.CounterVec{
|
||||
c.requests, c.requestBytes, c.responseBytes, c.refused,
|
||||
} {
|
||||
vec.DeleteLabelValues(least)
|
||||
}
|
||||
|
||||
c.top[country] = true
|
||||
|
||||
return country
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as the "Metrics
|
||||
// endpoint" section of SPEC.md lists them, and serves them in the
|
||||
// Prometheus text format. No metric carries a client's address.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||
type Metrics struct {
|
||||
registry *prometheus.Registry
|
||||
handler http.Handler
|
||||
|
||||
inFlight prometheus.Gauge
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
requestDuration prometheus.Histogram
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
countries *countries
|
||||
|
||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||
// that failed. GeoJSUnanswered are the requests whose client counted
|
||||
// as coming from an unknown country because GeoJS had not answered
|
||||
// about it in time.
|
||||
GeoJSRequests prometheus.Counter
|
||||
GeoJSFailures prometheus.Counter
|
||||
GeoJSUnanswered prometheus.Counter
|
||||
|
||||
stateFileWrites *prometheus.CounterVec
|
||||
stateFileWriteFailures *prometheus.CounterVec
|
||||
stateFileLastWrite *prometheus.GaugeVec
|
||||
stateFileSize *prometheus.GaugeVec
|
||||
stateFileEditsTakenIn *prometheus.CounterVec
|
||||
stateFileEditsSetAside *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// New returns the metrics, with the Go runtime's and the process's own.
|
||||
// topN is how many countries get series of their own
|
||||
// (SWWAF_METRICS_TOP_N).
|
||||
func New(topN int) *Metrics {
|
||||
byStatus := []string{"status_class", "action"}
|
||||
byFile := []string{"file"}
|
||||
|
||||
m := &Metrics{
|
||||
registry: prometheus.NewRegistry(),
|
||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "smallwebwaf_requests_in_flight",
|
||||
Help: "Requests under way.",
|
||||
}),
|
||||
requests: counterVec("smallwebwaf_requests_total",
|
||||
"Requests, by the class of their status and their action.", byStatus),
|
||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
||||
"Request body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
||||
"Response body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "smallwebwaf_request_duration_seconds",
|
||||
Help: "How long requests took, from their arrival to their end.",
|
||||
}),
|
||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "smallwebwaf_upstream_duration_seconds",
|
||||
Help: "How long requests passed to the app took, from then to their end.",
|
||||
}),
|
||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
||||
"Requests that broke a rate limit, by its window.",
|
||||
[]string{"window"}),
|
||||
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
||||
"Requests that passed a size or time limit, by its setting.",
|
||||
[]string{"limit"}),
|
||||
offences: counterVec("smallwebwaf_offences_total",
|
||||
"Offences, by kind.", []string{"kind"}),
|
||||
countries: newCountries(topN),
|
||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_geojs_requests_total",
|
||||
Help: "Requests to GeoJS.",
|
||||
}),
|
||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_geojs_failures_total",
|
||||
Help: "Requests to GeoJS that failed.",
|
||||
}),
|
||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_geojs_unanswered_total",
|
||||
Help: "Requests whose client counted as coming from an unknown " +
|
||||
"country because GeoJS had not answered about it in time.",
|
||||
}),
|
||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||
"Writes of each state file.", byFile),
|
||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
||||
"Writes of each state file that failed.", byFile),
|
||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
||||
"When each state file was last written, in seconds since 1970.", byFile),
|
||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
||||
"The size of each state file, as it was last written.", byFile),
|
||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
||||
"Edits of each state file taken in while running.", byFile),
|
||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||
byFile),
|
||||
}
|
||||
|
||||
m.handler = promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
|
||||
|
||||
m.registry.MustRegister(
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||
m.requestDuration, m.upstreamDuration,
|
||||
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences,
|
||||
m.countries.requests, m.countries.requestBytes, m.countries.responseBytes,
|
||||
m.countries.refused,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||
m.stateFileWrites, m.stateFileWriteFailures,
|
||||
m.stateFileLastWrite, m.stateFileSize,
|
||||
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
||||
)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// AddBansAndClients adds the metrics read from the ledger and the table
|
||||
// of clients as the metrics are asked for: the bans made since the start,
|
||||
// the bans active and permanent at now, and the clients in the table.
|
||||
func (m *Metrics) AddBansAndClients(
|
||||
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
|
||||
) {
|
||||
m.registry.MustRegister(
|
||||
// Every ban smallwebwaf makes so far is for a broken limit.
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_bans_made_total",
|
||||
Help: "Bans made, by cause.",
|
||||
ConstLabels: prometheus.Labels{"cause": "limit"},
|
||||
}, func() float64 {
|
||||
return float64(ledger.Made())
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "smallwebwaf_active_bans",
|
||||
Help: "Bans active now, the permanent ones included.",
|
||||
}, func() float64 {
|
||||
active, _ := ledger.Count(now())
|
||||
|
||||
return float64(active)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "smallwebwaf_permanent_bans",
|
||||
Help: "Permanent bans.",
|
||||
}, func() float64 {
|
||||
_, permanent := ledger.Count(now())
|
||||
|
||||
return float64(permanent)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "smallwebwaf_tracked_clients",
|
||||
Help: "Clients in the table of clients.",
|
||||
}, func() float64 {
|
||||
return float64(limiter.Len())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ServeHTTP answers with the metrics in the Prometheus text format.
|
||||
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
m.handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// RequestStarted counts a request as under way.
|
||||
func (m *Metrics) RequestStarted() {
|
||||
m.inFlight.Inc()
|
||||
}
|
||||
|
||||
// RequestEnded counts a request that has ended, from its log line. limit
|
||||
// is the setting whose size or time limit the request passed, "" if none.
|
||||
// duration is how long the request took, and upstreamDuration how long it
|
||||
// took from when it was passed to the app, zero if it was not.
|
||||
func (m *Metrics) RequestEnded(
|
||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
m.inFlight.Dec()
|
||||
|
||||
class := statusClass(line.Status)
|
||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
||||
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
||||
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
||||
m.requestDuration.Observe(duration.Seconds())
|
||||
|
||||
if upstreamDuration > 0 {
|
||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||
}
|
||||
|
||||
if line.LimitHit != "" {
|
||||
m.rateLimitHits.WithLabelValues(line.LimitHit).Inc()
|
||||
}
|
||||
|
||||
if limit != "" {
|
||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
||||
}
|
||||
|
||||
if line.Offence != "" {
|
||||
m.offences.WithLabelValues(line.Offence).Inc()
|
||||
}
|
||||
|
||||
if line.Country != "" {
|
||||
m.countries.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||
// that ended with err.
|
||||
func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
||||
m.stateFileWrites.WithLabelValues(name).Inc()
|
||||
|
||||
// The series of failures is there from the first write, at zero until
|
||||
// one fails.
|
||||
failures := m.stateFileWriteFailures.WithLabelValues(name)
|
||||
|
||||
if err != nil {
|
||||
failures.Inc()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
m.stateFileLastWrite.WithLabelValues(name).SetToCurrentTime()
|
||||
m.stateFileSize.WithLabelValues(name).Set(float64(size))
|
||||
}
|
||||
|
||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||
// taken in while smallwebwaf runs.
|
||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||
}
|
||||
|
||||
// StateFileEditSetAside counts an admin's edit of the state file name
|
||||
// renamed to name.bad because it did not parse.
|
||||
func (m *Metrics) StateFileEditSetAside(name string) {
|
||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||
}
|
||||
|
||||
// statusClass returns the class of status, such as 2xx, or none when no
|
||||
// status was sent.
|
||||
func statusClass(status int) string {
|
||||
if status == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
// A status's class is its hundreds: 404 is in 4xx.
|
||||
const hundred = 100
|
||||
|
||||
return strconv.Itoa(status/hundred) + "xx"
|
||||
}
|
||||
|
||||
// counterVec returns a counter named name, described by help, with a
|
||||
// series for each set of values of labels.
|
||||
func counterVec(name, help string, labels []string) *prometheus.CounterVec {
|
||||
return prometheus.NewCounterVec(prometheus.CounterOpts{Name: name, Help: help},
|
||||
labels)
|
||||
}
|
||||
|
||||
// gaugeVec returns a gauge named name, described by help, with a series
|
||||
// for each set of values of labels.
|
||||
func gaugeVec(name, help string, labels []string) *prometheus.GaugeVec {
|
||||
return prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: name, Help: help}, labels)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// answerAdmin answers a request for smallwebwaf itself, under
|
||||
// /_smallwebwaf/, once it has passed the checks: GET MetricsPath with
|
||||
// SWWAF_METRICS_TOKEN gets the metrics, and without it is refused with
|
||||
// 401. Any other request gets 404, as the metrics do while
|
||||
// SWWAF_METRICS_TOKEN is unset.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
rq.startClientResponseTimeout()
|
||||
|
||||
token := rq.h.config.MetricsToken
|
||||
|
||||
switch {
|
||||
case token == "" || rq.in.Method != http.MethodGet || rq.in.URL.Path != MetricsPath:
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
case !hasToken(rq.in, token):
|
||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||
rq.answer(refusal{
|
||||
status: http.StatusUnauthorized,
|
||||
action: requestlog.ActionAdmin,
|
||||
})
|
||||
default:
|
||||
rq.h.metrics.ServeHTTP(rq.out, rq.in)
|
||||
}
|
||||
}
|
||||
|
||||
// hasToken reports whether r carries token, as Authorization: Bearer
|
||||
// <token>.
|
||||
func hasToken(r *http.Request, token string) bool {
|
||||
scheme, sent, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
|
||||
return strings.EqualFold(scheme, "Bearer") &&
|
||||
subtle.ConstantTimeCompare([]byte(sent), []byte(token)) == 1
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||
// with action.
|
||||
func (rq *request) banResponse(action string) *refusal {
|
||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||
}
|
||||
|
||||
// banned reports whether a ban on a netblock the client is in covers the
|
||||
// request at now, and notes for the log line when that ban ends.
|
||||
func (rq *request) banned(now time.Time) bool {
|
||||
check := rq.h.ledger.Check
|
||||
if rq.h.config.Observe {
|
||||
check = rq.h.ledger.Find // in observe mode the ban refuses nothing
|
||||
}
|
||||
|
||||
ban, banned := check(rq.client, now)
|
||||
if banned {
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
}
|
||||
|
||||
return banned
|
||||
}
|
||||
|
||||
// limitBroken counts the request for the rate limits at now, notes the
|
||||
// client's counts for the log line, and reports whether the request takes
|
||||
// the client over a limit. In enforce mode such a request bans the
|
||||
// client's netblock, and sets the client's counters back to zero; in
|
||||
// observe mode it does neither.
|
||||
func (rq *request) limitBroken(now time.Time) bool {
|
||||
group := clientGroup(rq.client)
|
||||
|
||||
counts, hit, over := rq.h.limiter.Count(group, now)
|
||||
rq.line.Counts = counts
|
||||
|
||||
if !over {
|
||||
return false
|
||||
}
|
||||
|
||||
rq.line.LimitHit = hit.Window
|
||||
rq.line.Offence = requestlog.OffenceLimit
|
||||
|
||||
if rq.h.config.Observe {
|
||||
return true
|
||||
}
|
||||
|
||||
netblock := rq.netblock()
|
||||
ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
|
||||
Country: rq.line.Country,
|
||||
Limit: hit.Limit,
|
||||
Window: hit.Window,
|
||||
Count: hit.Requests,
|
||||
Request: bans.Request{
|
||||
Time: now,
|
||||
Method: rq.in.Method,
|
||||
Host: rq.in.Host,
|
||||
Path: rq.in.URL.RequestURI(),
|
||||
Status: rq.h.config.BanResponse,
|
||||
UserAgent: rq.in.UserAgent(),
|
||||
},
|
||||
// The histories count this request only once it has ended.
|
||||
Requests: rq.h.limiter.Requests(netblock) + 1,
|
||||
})
|
||||
rq.h.limiter.Reset(group)
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// netblock is the netblock a ban on the client covers: its IPv4 address,
|
||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// counts it in.
|
||||
func (rq *request) netblock() netip.Prefix {
|
||||
addr := rq.client.Unmap()
|
||||
if addr.Is4() {
|
||||
return netip.PrefixFrom(addr, rq.h.config.BanScopeV4Prefix).Masked()
|
||||
}
|
||||
|
||||
return clientGroup(addr)
|
||||
}
|
||||
|
||||
// banExpires is when ban ends, as the log line gives it: a time, or
|
||||
// permanent.
|
||||
func banExpires(ban bans.Ban) string {
|
||||
if ban.Permanent() {
|
||||
return "permanent"
|
||||
}
|
||||
|
||||
return requestlog.FormatTime(ban.Expires)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// otherClient is a client next to client.
|
||||
otherClient = "203.0.113.10"
|
||||
// userAgent is the user agent of every request a sender sends.
|
||||
userAgent = "ban-test/1.0"
|
||||
// permanent is the log line's ban_expires for a permanent ban.
|
||||
permanent = "permanent"
|
||||
)
|
||||
|
||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
// The request over the limit of one a minute is refused, and bans the
|
||||
// client for an hour, the default.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != expires {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
"want minute, limit and %s", line.LimitHit, line.Offence, line.BanExpires,
|
||||
expires)
|
||||
}
|
||||
|
||||
// Every request while the ban lasts is refused.
|
||||
clk.advance(time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
||||
"want %s and neither of the others", line.BanExpires, line.Offence,
|
||||
line.LimitHit, expires)
|
||||
}
|
||||
|
||||
// Once it ends, the client is let through.
|
||||
clk.advance(time.Second)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
limitBanDuration: "10m",
|
||||
limitBanRepeatWindow: "1h",
|
||||
maxBanDuration: "1h",
|
||||
})
|
||||
|
||||
// breakLimit has client go over the limit of one a minute, and
|
||||
// returns when the ban that makes ends.
|
||||
breakLimit := func() string {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
||||
}
|
||||
wantExpires := func(got string, length time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
want := requestlog.FormatTime(clk.Now().Add(length))
|
||||
if got != want {
|
||||
t.Errorf("ban ends at %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10*time.Minute + time.Hour)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10 * time.Minute)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
||||
// permanent.
|
||||
clk.advance(30 * time.Minute)
|
||||
|
||||
got := breakLimit()
|
||||
if got != permanent {
|
||||
t.Errorf("ban ends at %s, want a permanent one", got)
|
||||
}
|
||||
|
||||
clk.advance(365 * 24 * time.Hour)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{rateLimitPerDay: "2"})
|
||||
|
||||
// The third request in a day is over the limit of two, and bans the
|
||||
// client for an hour.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for range 3 {
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// Later the same day the client has its whole allowance again: the
|
||||
// ban set its counters back to zero, and the requests it refused were
|
||||
// not counted for the rate limits, only in its notes.
|
||||
clk.advance(time.Hour)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||
t.Errorf("bans %+v, want two, the first with 3 requests refused", banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||
const (
|
||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
breaker string // the client that breaks the limit
|
||||
refused []string
|
||||
let []string // let through
|
||||
}{
|
||||
{
|
||||
"an IPv4 address, by default", nil, client,
|
||||
nil, []string{otherClient, exempt},
|
||||
},
|
||||
{
|
||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||
},
|
||||
{
|
||||
"an IPv6 /64", nil, "2001:db8:5::1",
|
||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
}
|
||||
maps.Copy(env, tc.env)
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for _, sent := range tc.refused {
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
for _, sent := range tc.let {
|
||||
s.get(sent, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.Country != "" {
|
||||
t.Errorf("log line has country %q, want none", line.Country)
|
||||
}
|
||||
|
||||
if !slices.Equal(asked(), []string{fromDE}) {
|
||||
t.Errorf("GeoJS was asked about %v, want %s alone", asked(), fromDE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
||||
status int // 0 is the connection closed without an answer
|
||||
}{
|
||||
{"", http.StatusForbidden},
|
||||
{"403", http.StatusForbidden},
|
||||
{"429", http.StatusTooManyRequests},
|
||||
{"close", 0},
|
||||
} {
|
||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
deniedCountries: "kp",
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
env[banResponse] = tc.setting
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, geojsURL, env)
|
||||
|
||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.request(fromDE, "/repo/commits?page=2",
|
||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(time.Hour),
|
||||
Notes: bans.Notes{
|
||||
Country: "DE",
|
||||
Limit: 1,
|
||||
Window: minute,
|
||||
Count: 2,
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: "/repo/commits?page=2",
|
||||
Status: http.StatusForbidden,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
// The one let through, the one that broke the limit and the two
|
||||
// refused under the ban.
|
||||
Requests: 4,
|
||||
Refused: 2,
|
||||
EarlierBans: 0,
|
||||
},
|
||||
}
|
||||
|
||||
ledger := server.Ledger
|
||||
|
||||
got := ledger.Bans(netblock)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// The next ban counts this one among the earlier.
|
||||
clk.advance(time.Hour)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
got = ledger.Bans(netblock)
|
||||
if len(got) != 2 || got[1].Notes.EarlierBans != 1 {
|
||||
t.Errorf("bans %+v, want two, the second with one earlier ban", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
maxBans: "1",
|
||||
})
|
||||
|
||||
// One ban is held, so otherClient's ban drops client's.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
||||
// makes bans.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// Now tells the time.
|
||||
func (c *clock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.now
|
||||
}
|
||||
|
||||
// advance moves the clock on by d.
|
||||
func (c *clock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
||||
// with the settings in env on top of trusting localhost's
|
||||
// X-Forwarded-For, clients' countries looked up at geojsURL, and a clock
|
||||
// set to midnight, the start of a bucket in every window.
|
||||
func startWithClock(
|
||||
t *testing.T, geojsURL string, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
settings := map[string]string{trustedProxies: trustLocalhost}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to smallwebwaf one after another, each on a
|
||||
// connection of its own, and checks each one's answer and log line. They
|
||||
// must be the only requests smallwebwaf is sent, since the log lines are
|
||||
// matched to them in order.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
|
||||
// get sends a GET request for / from the client at from.
|
||||
func (s *sender) get(from string, status int, action string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
return s.request(from, "/", status, action)
|
||||
}
|
||||
|
||||
// request sends a GET request for path from the client at from, as
|
||||
// X-Forwarded-For names it, and checks that its answer and its log line
|
||||
// have status, 0 for the connection closed without an answer, and that
|
||||
// the line has action. It returns the log line.
|
||||
func (s *sender) request(from, path string, status int, action string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, _ := s.requestWithHeader(from, path, "", status, action)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// requestWithHeader is request with header, such as "Authorization:
|
||||
// Bearer x", added to the request unless it is "". It returns the body of
|
||||
// the answer too.
|
||||
func (s *sender) requestWithHeader(
|
||||
from, path, header string, status int, action string,
|
||||
) (logLine, string) {
|
||||
s.t.Helper()
|
||||
|
||||
if header != "" {
|
||||
header += "\r\n"
|
||||
}
|
||||
|
||||
conn := dial(s.t, s.addr)
|
||||
send(s.t, conn, "GET "+path+" HTTP/1.1\r\nHost: "+appHost+
|
||||
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+
|
||||
header+"\r\n")
|
||||
|
||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
s.t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
|
||||
var got answer
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
got = readAnswer(res)
|
||||
case !errors.Is(err, io.ErrUnexpectedEOF):
|
||||
s.t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
if got.status != status {
|
||||
s.t.Errorf("request %d, from %s: status %d, want %d", s.sent+1, from,
|
||||
got.status, status)
|
||||
}
|
||||
|
||||
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, action)
|
||||
|
||||
return line, string(got.body)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ func (b *requestBody) Read(p []byte) (int, error) {
|
||||
b.rq.refuse(refusal{
|
||||
status: http.StatusRequestEntityTooLarge,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_REQUEST_MAX_BYTES",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ func (b *responseBody) Read(p []byte) (int, error) {
|
||||
b.rq.refuse(refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_RESPONSE_MAX_BYTES",
|
||||
})
|
||||
|
||||
return n, errResponseTooLarge
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -48,6 +49,33 @@ func clientAddress(
|
||||
return client
|
||||
}
|
||||
|
||||
// requestIDHeader carries the request's id, from traefik and to the app.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// requestID is the request's id: the one a trusted proxy sent, or a new
|
||||
// random one. A peer outside the trusted proxies did not come through
|
||||
// traefik, so the id it sends is its own claim, and is replaced.
|
||||
func requestID(r *http.Request, peerTrusted bool) string {
|
||||
id := r.Header.Get(requestIDHeader)
|
||||
if !peerTrusted || id == "" {
|
||||
id = rand.Text()
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// scheme is how the client reached traefik, as a trusted proxy says in
|
||||
// X-Forwarded-Proto, or otherwise http, the only scheme smallwebwaf
|
||||
// serves.
|
||||
func scheme(r *http.Request, peerTrusted bool) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if !peerTrusted || proto == "" {
|
||||
return "http"
|
||||
}
|
||||
|
||||
return proto
|
||||
}
|
||||
|
||||
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
|
||||
const ipv6GroupPrefix = 64
|
||||
|
||||
|
||||
@@ -14,10 +14,14 @@ const (
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
// secure is the scheme a client reached traefik with.
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the
|
||||
// one smallwebwaf serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// appHeaders is what the app tells about the headers it received.
|
||||
@@ -65,13 +69,13 @@ func TestClientAddressAndForwardedHeaders(t *testing.T) {
|
||||
func clientAddressCases() []clientAddressCase {
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
forged := http.Header{
|
||||
forwardedFor: {client},
|
||||
"X-Forwarded-Host": {"forged.example"},
|
||||
"X-Forwarded-Proto": {secure},
|
||||
"X-Real-Ip": {client},
|
||||
forwardedFor: {client},
|
||||
"X-Forwarded-Host": {"forged.example"},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
}
|
||||
replaced := appHeaders{
|
||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: "http",
|
||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
|
||||
}
|
||||
|
||||
return []clientAddressCase{{
|
||||
@@ -87,10 +91,10 @@ func clientAddressCases() []clientAddressCase {
|
||||
"outside the trusted proxies from the right",
|
||||
env: trusted,
|
||||
header: http.Header{
|
||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||
"X-Forwarded-Host": {appHost},
|
||||
"X-Forwarded-Proto": {secure},
|
||||
"X-Real-Ip": {client},
|
||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||
"X-Forwarded-Host": {appHost},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
},
|
||||
wantClient: client,
|
||||
wantApp: appHeaders{
|
||||
@@ -138,7 +142,7 @@ func requestWithHeaders(
|
||||
Host: r.Host,
|
||||
ForwardedFor: r.Header.Get(forwardedFor),
|
||||
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
||||
ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
|
||||
ForwardedProto: r.Header.Get(forwardedProto),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,14 +21,18 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
// the last one would have it refused.
|
||||
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
const healthChecks = 3
|
||||
const (
|
||||
healthChecks = 3
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := get(t, addr, proxy.HealthPath)
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" {
|
||||
t.Errorf("health endpoint answered %q, want ok", got.body)
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
"with %q", got.body, got.header.Get("Content-Type"), contentType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +41,11 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
lines := out.requestLines(t, healthChecks+1)
|
||||
for _, line := range lines[:healthChecks] {
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
|
||||
|
||||
if line.ResponseContentType != contentType {
|
||||
t.Errorf("health check's log line has response_content_type %q, "+
|
||||
"want %q", line.ResponseContentType, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
rateLimitPerMinute: "2",
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Two let through, one over the limit, which bans the client, and one
|
||||
// refused under that ban, for which the country is not looked up.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
clk.advance(time.Second)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
clk.advance(time.Second)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
want := ratelimit.History{
|
||||
FirstSeen: start,
|
||||
LastSeen: start.Add(2 * time.Second),
|
||||
Country: "DE",
|
||||
LookedUp: start.Add(time.Second),
|
||||
Requests: 4,
|
||||
Forwarded: 2,
|
||||
Refused: 2,
|
||||
// The app answers with no body, smallwebwaf with its status text.
|
||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||
Offences: ratelimit.Offences{Limit: 1},
|
||||
}
|
||||
|
||||
got := historyOf(t, server, fromDE)
|
||||
if got != want {
|
||||
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
out.requestLine(t)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
||||
t.Errorf("history counts %d bytes in and %d out, want 3 and 5",
|
||||
history.RequestBytes, history.ResponseBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpointIsNotInTheHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
|
||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||
out.requestLine(t)
|
||||
|
||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||
t.Errorf("the table holds %+v, want no client", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestForSmallwebwafIsRefusedOnlyWithoutTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
|
||||
map[string]string{metricsToken: token})
|
||||
|
||||
// The metrics and the 404 are neither forwarded nor refused; the 401
|
||||
// is refused.
|
||||
scrape(t, addr)
|
||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||
wantStatus(t, get(t, addr, proxy.MetricsPath), http.StatusUnauthorized)
|
||||
out.requestLines(t, 3)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.Requests != 3 || history.Forwarded != 0 || history.Refused != 1 {
|
||||
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
|
||||
"want 3, 0 and 1", history.Requests, history.Forwarded, history.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
// historyOf returns the history of the client at addr.
|
||||
func historyOf(t *testing.T, server *proxy.Server, addr string) ratelimit.History {
|
||||
t.Helper()
|
||||
|
||||
client := netip.MustParsePrefix(addr + "/32")
|
||||
for _, c := range server.Limiter.Snapshot() {
|
||||
if c.Client == client {
|
||||
return c.History
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("%s is not in the table", client)
|
||||
|
||||
return ratelimit.History{}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func TestRequestBodyLimit(t *testing.T) {
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
requestMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
var body io.Reader = bytes.NewReader(make([]byte, tc.size))
|
||||
@@ -66,6 +67,13 @@ func TestRequestBodyLimit(t *testing.T) {
|
||||
tc.want)
|
||||
wantLine(t, out.requestLine(t), tc.want, tc.action)
|
||||
|
||||
hits := 0
|
||||
if tc.action == requestlog.ActionTooLarge {
|
||||
hits = 1
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, requestMaxBytes, hits)
|
||||
|
||||
if tc.refusedBeforeApp && calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
}
|
||||
@@ -106,6 +114,7 @@ func TestResponseBodyLimit(t *testing.T) {
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
responseMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
got := get(t, addr, "/download")
|
||||
@@ -123,6 +132,13 @@ func TestResponseBodyLimit(t *testing.T) {
|
||||
if line.UpstreamStatus != http.StatusOK {
|
||||
t.Errorf("log line has upstream_status %d", line.UpstreamStatus)
|
||||
}
|
||||
|
||||
hits := 0
|
||||
if tc.action == requestlog.ActionTooLarge {
|
||||
hits = 1
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, responseMaxBytes, hits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a
|
||||
// request carries it.
|
||||
token = "0123456789abcdef0123456789abcdef"
|
||||
bearer = "Bearer " + token
|
||||
)
|
||||
|
||||
func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for i, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody)
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
wantStatus(t, do(t, req), http.StatusNotFound)
|
||||
wantLine(t, out.requestLines(t, i+1)[i], http.StatusNotFound,
|
||||
requestlog.ActionAdmin)
|
||||
}
|
||||
|
||||
if calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
|
||||
|
||||
for i, tc := range []struct {
|
||||
method, path, authorization string
|
||||
status int
|
||||
}{
|
||||
{http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized},
|
||||
{
|
||||
http.MethodGet, proxy.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||
http.StatusUnauthorized,
|
||||
},
|
||||
{http.MethodGet, proxy.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
||||
{http.MethodGet, proxy.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, proxy.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, proxy.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, proxy.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
||||
{http.MethodPost, proxy.HealthPath, "", http.StatusNotFound},
|
||||
} {
|
||||
req := newRequest(t, tc.method, addr, tc.path, http.NoBody)
|
||||
if tc.authorization != "" {
|
||||
req.Header.Set("Authorization", tc.authorization)
|
||||
}
|
||||
|
||||
got := do(t, req)
|
||||
wantStatus(t, got, tc.status)
|
||||
wantLine(t, out.requestLines(t, i+1)[i], tc.status, requestlog.ActionAdmin)
|
||||
|
||||
if tc.status == http.StatusUnauthorized &&
|
||||
got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Errorf("%q was answered without WWW-Authenticate: Bearer",
|
||||
tc.authorization)
|
||||
}
|
||||
|
||||
if tc.status == http.StatusOK &&
|
||||
!strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") {
|
||||
t.Errorf("the metrics are\n%s", got.body)
|
||||
}
|
||||
}
|
||||
|
||||
if calls.Load() != 0 {
|
||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAreAskedForThroughTheChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// Asking for the metrics counts toward the client's limit of one
|
||||
// request a minute, so its next request breaks it, and bans it. A
|
||||
// banned client is refused the metrics too.
|
||||
s.scrape(client)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.requestWithHeader(client, proxy.MetricsPath, "Authorization: "+bearer,
|
||||
http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestMetricsCountTheTraffic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
arrived, release := make(chan struct{}), make(chan struct{})
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
if r.URL.Path == "/held" {
|
||||
close(arrived)
|
||||
<-release
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
releaseApp := sync.OnceFunc(func() { close(release) })
|
||||
t.Cleanup(releaseApp)
|
||||
|
||||
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||
out.requestLines(t, 2)
|
||||
|
||||
forward := `{action="forward",status_class="2xx"}`
|
||||
notFound := `{action="admin",status_class="4xx"}`
|
||||
|
||||
// The request for the metrics is itself under way.
|
||||
metrics := scrape(t, addr)
|
||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
||||
float64(len("Not Found\n")))
|
||||
wantMetric(t, metrics, "smallwebwaf_request_duration_seconds_count", 2)
|
||||
wantMetric(t, metrics, "smallwebwaf_upstream_duration_seconds_count", 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_requests_in_flight", 1)
|
||||
metric(t, metrics, "go_goroutines")
|
||||
metric(t, metrics, "process_start_time_seconds")
|
||||
|
||||
// A request the app holds is under way until it ends.
|
||||
httpClient := newClient(t)
|
||||
held := newRequest(t, http.MethodGet, addr, "/held", http.NoBody)
|
||||
ended := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
res, err := httpClient.Do(held)
|
||||
if err == nil {
|
||||
err = readAnswer(res).err
|
||||
}
|
||||
|
||||
ended <- err
|
||||
}()
|
||||
|
||||
<-arrived
|
||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 2)
|
||||
releaseApp()
|
||||
|
||||
err := <-ended
|
||||
if err != nil {
|
||||
t.Fatalf("held request: %v", err)
|
||||
}
|
||||
|
||||
out.requestLines(t, 5)
|
||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 1)
|
||||
}
|
||||
|
||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
)
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
rateLimitExemptNets: scraper,
|
||||
denyNets: denied,
|
||||
banResponse: "close",
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
})
|
||||
|
||||
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
||||
s.get(denied, 0, requestlog.ActionDenied)
|
||||
|
||||
// A first broken limit bans for an hour.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionRateLimited)
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_requests_total{action="denied",status_class="none"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 0)
|
||||
|
||||
clk.advance(time.Hour)
|
||||
wantMetric(t, s.scrape(scraper), "smallwebwaf_active_bans", 0)
|
||||
|
||||
// A limit broken again right after would ban for three hours, longer
|
||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionRateLimited)
|
||||
|
||||
metrics = s.scrape(scraper)
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 2)
|
||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
|
||||
// denied, client, and the scraper as of its earlier requests.
|
||||
wantMetric(t, metrics, "smallwebwaf_tracked_clients", 3)
|
||||
}
|
||||
|
||||
func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const fromFR = "198.51.100.20"
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
env := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
metricsTopN: "2",
|
||||
deniedCountries: "kp",
|
||||
}
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, env)
|
||||
|
||||
// The answers are kept before the requests, so that none waits for
|
||||
// GeoJS.
|
||||
server.GeoJS.Load([]lookup.Answer{
|
||||
keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"), keptAnswer(fromFR, "FR"),
|
||||
})
|
||||
|
||||
lines := 0
|
||||
send := func(from string, times, status int) {
|
||||
t.Helper()
|
||||
|
||||
for range times {
|
||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))
|
||||
req.Header.Set(forwardedFor, from)
|
||||
wantStatus(t, do(t, req), status)
|
||||
|
||||
// Each is counted before the next is sent, so that the
|
||||
// countries are ranked in the order sent.
|
||||
lines++
|
||||
out.requestLines(t, lines)
|
||||
}
|
||||
}
|
||||
|
||||
// With two countries of their own, the third is counted as other.
|
||||
send(fromKP, 3, http.StatusForbidden)
|
||||
send(fromDE, 2, http.StatusOK)
|
||||
send(fromFR, 1, http.StatusOK)
|
||||
|
||||
metrics := scrape(t, addr)
|
||||
lines++
|
||||
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_list_refusals_total{country="KP"}`, 3)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="KP"}`, 0)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`, 6)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="KP"}`,
|
||||
float64(3*len("Forbidden\n")))
|
||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="other"}`,
|
||||
float64(len("hello")))
|
||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`)
|
||||
|
||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||
// from then on, and DE's is gone.
|
||||
send(fromFR, 3, http.StatusOK)
|
||||
|
||||
metrics = scrape(t, addr)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 2)
|
||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`)
|
||||
wantNoSeries(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`)
|
||||
}
|
||||
|
||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(geojs.Close)
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, fromDE)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
// The client stops waiting for GeoJS after a second, so GeoJS's
|
||||
// failure can come after its request has ended.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
metrics := scrape(t, addr)
|
||||
|
||||
for metric(t, metrics, "smallwebwaf_geojs_failures_total") == 0 &&
|
||||
time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
metrics = scrape(t, addr)
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, "smallwebwaf_geojs_requests_total", 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_geojs_failures_total", 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_geojs_unanswered_total", 1)
|
||||
}
|
||||
|
||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||
// country, given now.
|
||||
func keptAnswer(addr, country string) lookup.Answer {
|
||||
now := time.Now()
|
||||
|
||||
return lookup.Answer{
|
||||
Client: netip.MustParsePrefix(addr + "/32"), Country: country,
|
||||
Answered: now, Used: now,
|
||||
}
|
||||
}
|
||||
|
||||
// scrape asks smallwebwaf at addr for the metrics, with the token, and
|
||||
// returns them.
|
||||
func scrape(t *testing.T, addr string) string {
|
||||
t.Helper()
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody)
|
||||
req.Header.Set("Authorization", bearer)
|
||||
|
||||
got := do(t, req)
|
||||
if got.status != http.StatusOK {
|
||||
t.Fatalf("the metrics were answered %d", got.status)
|
||||
}
|
||||
|
||||
return string(got.body)
|
||||
}
|
||||
|
||||
// scrape asks for the metrics, with the token, from the client at from,
|
||||
// and returns them.
|
||||
func (s *sender) scrape(from string) string {
|
||||
s.t.Helper()
|
||||
|
||||
_, metrics := s.requestWithHeader(from, proxy.MetricsPath, "Authorization: "+bearer,
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// metric returns the value of series in metrics, which are in the
|
||||
// Prometheus text format. series is a name and its labels in the order of
|
||||
// their names, such as smallwebwaf_offences_total{kind="limit"}. It fails
|
||||
// the test if there is no such series.
|
||||
func metric(t *testing.T, metrics, series string) float64 {
|
||||
t.Helper()
|
||||
|
||||
for line := range strings.Lines(metrics) {
|
||||
value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
number, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("%s has the value %q", series, value)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
t.Fatalf("no series %s in the metrics:\n%s", series, metrics)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// wantMetric checks the value of series in metrics, as metric reads it.
|
||||
func wantMetric(t *testing.T, metrics, series string, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := metric(t, metrics, series)
|
||||
if got != want {
|
||||
t.Errorf("%s is %v, want %v", series, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantNoSeries checks that metrics have no series series.
|
||||
func wantNoSeries(t *testing.T, metrics, series string) {
|
||||
t.Helper()
|
||||
|
||||
if strings.Contains(metrics, "\n"+series+" ") {
|
||||
t.Errorf("there is a series %s", series)
|
||||
}
|
||||
}
|
||||
|
||||
// wantLimitHits checks that the metrics of smallwebwaf at addr count hits
|
||||
// requests that passed the size or time limit of the setting limit, with
|
||||
// no series for it when hits is 0.
|
||||
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
||||
t.Helper()
|
||||
|
||||
series := `smallwebwaf_size_and_time_limit_hits_total{limit="` + limit + `"}`
|
||||
metrics := scrape(t, addr)
|
||||
|
||||
if hits == 0 {
|
||||
wantNoSeries(t, metrics, series)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, series, float64(hits))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// observe is the value of SWWAF_MODE for observe mode.
|
||||
const observe = "observe"
|
||||
|
||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
banned = otherClient // under a ban read from bans.json
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWWAF_MODE at its default
|
||||
observe bool
|
||||
}{
|
||||
{"", false},
|
||||
{"enforce", false},
|
||||
{observe, true},
|
||||
} {
|
||||
t.Run(mode+"="+tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
deniedCountries: "kp",
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
env[mode] = tc.setting
|
||||
}
|
||||
|
||||
s, clk, server := startWithClock(t, geojsURL, env)
|
||||
server.Ledger.Load([]bans.Ban{{
|
||||
Netblock: netip.MustParsePrefix(banned + "/32"),
|
||||
Start: clk.Now(),
|
||||
Expires: clk.Now().Add(time.Hour),
|
||||
}})
|
||||
|
||||
// fromDE's first request is within the limit of one a minute,
|
||||
// and its second breaks it.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for _, sent := range []struct{ from, refusal string }{
|
||||
{denied, requestlog.ActionDenied},
|
||||
{banned, requestlog.ActionBanned},
|
||||
{fromKP, requestlog.ActionCountryDenied},
|
||||
{fromDE, requestlog.ActionRateLimited},
|
||||
} {
|
||||
if !tc.observe {
|
||||
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||
wantWouldAction(t, line, "")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Passed to the app, which answered it.
|
||||
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, sent.refusal)
|
||||
|
||||
if line.UpstreamStatus != http.StatusOK {
|
||||
t.Errorf("log line has upstream_status %d, want 200",
|
||||
line.UpstreamStatus)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
kept := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
||||
Start: clk.Now(),
|
||||
Expires: clk.Now().Add(time.Hour),
|
||||
}
|
||||
server.Ledger.Load([]bans.Ban{kept})
|
||||
|
||||
// No ban sets client's counters back to zero, so each request after
|
||||
// the first breaks the limit of one a minute.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for range 2 {
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||
|
||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != "" {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
"want minute, limit and none", line.LimitHit, line.Offence,
|
||||
line.BanExpires)
|
||||
}
|
||||
}
|
||||
|
||||
// The ban read from bans.json refuses nothing, and so counts no
|
||||
// refusal in its notes, but is kept.
|
||||
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||
|
||||
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||
requestlog.FormatTime(kept.Expires))
|
||||
}
|
||||
|
||||
got := server.Ledger.Snapshot()
|
||||
if len(got) != 1 || got[0] != kept {
|
||||
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeKeepsTheSizeLimitsAndTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
answerWithSize(w, 2*sizeLimit, true)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
mode: observe,
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
requestMaxBytes: sizeLimitSetting,
|
||||
responseMaxBytes: sizeLimitSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
// SWWAF_DENY_NETS would refuse each request; instead a size limit or
|
||||
// the missing token does.
|
||||
for i, tc := range []struct {
|
||||
method, path string
|
||||
body io.Reader
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{
|
||||
http.MethodPost, "/upload", bytes.NewReader(make([]byte, 2*sizeLimit)),
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge,
|
||||
},
|
||||
{
|
||||
http.MethodGet, "/download", http.NoBody,
|
||||
http.StatusBadGateway, requestlog.ActionTooLarge,
|
||||
},
|
||||
{
|
||||
http.MethodGet, proxy.MetricsPath, http.NoBody,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin,
|
||||
},
|
||||
} {
|
||||
req := newRequest(t, tc.method, addr, tc.path, tc.body)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), tc.status)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
wantLine(t, line, tc.status, tc.action)
|
||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||
}
|
||||
|
||||
// The upload was refused before it reached the app.
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// wantWouldAction checks the request log line's would_action, and that a
|
||||
// line that should have none has no such field.
|
||||
func wantWouldAction(t *testing.T, line logLine, want string) {
|
||||
t.Helper()
|
||||
|
||||
got, present := line.fields["would_action"]
|
||||
|
||||
switch {
|
||||
case want == "" && present:
|
||||
t.Errorf("log line has would_action %v, want none", got)
|
||||
case want != "" && got != want:
|
||||
t.Errorf("log line has would_action %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -115,27 +118,34 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantRequestFields checks the log line's fields about the request.
|
||||
// wantRequestFields checks the log line's fields about the request. Its
|
||||
// time, its id and its timings are checked only for being there.
|
||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||
t.Helper()
|
||||
|
||||
want := requestlog.Line{
|
||||
Type: "request", Time: line.Time, ClientIP: localhost, PeerIP: localhost,
|
||||
Method: http.MethodPatch, Host: host, Path: rawPath, Query: rawQuery,
|
||||
Protocol: "HTTP/1.1", Status: http.StatusTeapot,
|
||||
UpstreamStatus: http.StatusTeapot, RequestBytes: int64(sent),
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: hostname,
|
||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
||||
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
||||
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
||||
ResponseBytes: int64(received), UserAgent: "test-agent",
|
||||
Action: requestlog.ActionForward, DurationTotal: line.DurationTotal,
|
||||
DurationUpstreamTotal: line.DurationUpstreamTotal,
|
||||
}
|
||||
if line.Line != want {
|
||||
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
||||
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
}
|
||||
|
||||
_, err := time.Parse(time.RFC3339, line.Time)
|
||||
if err != nil || line.DurationTotal <= 0 || line.DurationUpstreamTotal <= 0 {
|
||||
t.Errorf("log line has time %q and durations %v and %v",
|
||||
line.Time, line.DurationTotal, line.DurationUpstreamTotal)
|
||||
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
|
||||
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
|
||||
t.Errorf("log line has time %q, request_id %q and durations %v and %v",
|
||||
line.Time, line.RequestID, line.DurationTotal,
|
||||
line.fields["duration_upstream_total"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +307,7 @@ func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHasTheFixedLimits(t *testing.T) {
|
||||
func TestServerHasTheDefaultLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := config.FromEnvironment(func(string) (string, bool) { return "", false })
|
||||
@@ -319,37 +329,48 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusesHeadersOver32KiB(t *testing.T) {
|
||||
func TestRefusesHeadersOverTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, nil)
|
||||
|
||||
// size counts every byte of the request: the request line, the
|
||||
// headers and the blank line that ends them.
|
||||
const (
|
||||
start = "GET / HTTP/1.1\r\nHost: app\r\nX-Large: "
|
||||
end = "\r\n\r\n"
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
size int
|
||||
want int
|
||||
name string
|
||||
env map[string]string
|
||||
limit int
|
||||
}{
|
||||
{size: 32 << 10, want: http.StatusOK},
|
||||
{size: 32<<10 + 1, want: http.StatusRequestHeaderFieldsTooLarge},
|
||||
{"by default", nil, 32 << 10},
|
||||
{"as set", map[string]string{clientHeaderMaxBytes: "8K"}, 8 << 10},
|
||||
} {
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, start+strings.Repeat("a", tc.size-len(start)-len(end))+end)
|
||||
wantStatus(t, readResponse(t, conn), tc.want)
|
||||
}
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, _ := startProxy(t, app.URL, tc.env)
|
||||
|
||||
// size counts every byte of the request: the request line,
|
||||
// the headers and the blank line that ends them.
|
||||
const (
|
||||
start = "GET / HTTP/1.1\r\nHost: app\r\nX-Large: "
|
||||
end = "\r\n\r\n"
|
||||
)
|
||||
|
||||
for _, sent := range []struct{ size, want int }{
|
||||
{tc.limit, http.StatusOK},
|
||||
{tc.limit + 1, http.StatusRequestHeaderFieldsTooLarge},
|
||||
} {
|
||||
conn := dial(t, addr)
|
||||
send(t, conn,
|
||||
start+strings.Repeat("a", sent.size-len(start)-len(end))+end)
|
||||
wantStatus(t, readResponse(t, conn), sent.want)
|
||||
}
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +381,13 @@ func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
||||
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
||||
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
||||
wantLine(t, out.requestLine(t), http.StatusBadGateway,
|
||||
requestlog.ActionUpstreamError)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusBadGateway, requestlog.ActionUpstreamError)
|
||||
|
||||
// There never was a connection to the app, nor an answer from it.
|
||||
wantTimings(t, line, "duration_total", "duration_checks",
|
||||
"duration_upstream_total")
|
||||
|
||||
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
|
||||
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
||||
|
||||
+95
-39
@@ -8,37 +8,35 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The request line and headers a client may send, and how long a
|
||||
// kept-open client connection may wait for its next request, are fixed
|
||||
// rather than settings. The limit on the request line and headers is
|
||||
// 32 KiB, but Go's server reads 4 KiB past its MaxHeaderBytes before it
|
||||
// refuses, so MaxHeaderBytes is set 4 KiB lower. The idle time is longer
|
||||
// than the 90 seconds after which traefik closes a connection it is not
|
||||
// using, so traefik never sends a request on a connection smallwebwaf is
|
||||
// closing.
|
||||
const (
|
||||
requestHeaderMaxBytes = 32<<10 - 4<<10
|
||||
clientIdleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// How smallwebwaf keeps connections to the app open between requests.
|
||||
const (
|
||||
appIdleConns = 100
|
||||
appIdleConnTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// adminPrefix starts the path of every request for smallwebwaf itself,
|
||||
// which never reaches the app.
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// HealthPath is smallwebwaf's health endpoint, which the container's
|
||||
// health check asks.
|
||||
const HealthPath = "/_smallwebwaf/healthz"
|
||||
|
||||
// MetricsPath is where the metrics are, for a request that carries
|
||||
// SWWAF_METRICS_TOKEN.
|
||||
const MetricsPath = "/_smallwebwaf/metrics"
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
@@ -49,39 +47,77 @@ type Params struct {
|
||||
// GeoJSURL is where clients' countries are looked up, normally
|
||||
// lookup.URL. GeoJS is asked only while a country list is set.
|
||||
GeoJSURL string
|
||||
// Now tells the time by which requests are counted for the rate
|
||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||
// normally time.Now in UTC, the time the state files give.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||
// whose state the state files keep, and the metrics.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
Ledger *bans.Ledger
|
||||
Limiter *ratelimit.Limiter
|
||||
GeoJS *lookup.GeoJS
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// New returns the server smallwebwaf runs: each request it reads passes
|
||||
// through the proxy. Go's server itself refuses headers over 32 KiB, with
|
||||
// 431, closes a connection idle for 120 seconds, and applies
|
||||
// through the proxy. Go's server itself refuses a request line and
|
||||
// headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, with 431, closes a
|
||||
// connection idle for SWWAF_CLIENT_IDLE_TIMEOUT, and applies
|
||||
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
||||
// applies the timeouts and size limits from then on.
|
||||
func New(params Params) *http.Server {
|
||||
func New(params Params) *Server {
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
m := metrics.New(params.Config.MetricsTopN)
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
limiter: ratelimit.New(ratelimit.Limits{
|
||||
PerMinute: params.Config.RateLimitPerMinute,
|
||||
PerHour: params.Config.RateLimitPerHour,
|
||||
PerDay: params.Config.RateLimitPerDay,
|
||||
}),
|
||||
ledger: bans.New(bans.Rules{
|
||||
LimitBanDuration: params.Config.LimitBanDuration,
|
||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||
MaxBanDuration: params.Config.MaxBanDuration,
|
||||
MaxBans: params.Config.MaxBans,
|
||||
}),
|
||||
geojs: lookup.New(lookup.Params{
|
||||
URL: params.GeoJSURL,
|
||||
Now: params.Now,
|
||||
ProcessLog: params.ProcessLog,
|
||||
Metrics: m,
|
||||
}),
|
||||
}
|
||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||
|
||||
return &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
limiter: ratelimit.New(ratelimit.Limits{
|
||||
PerMinute: params.Config.RateLimitPerMinute,
|
||||
PerHour: params.Config.RateLimitPerHour,
|
||||
PerDay: params.Config.RateLimitPerDay,
|
||||
}),
|
||||
geojs: lookup.New(lookup.Params{
|
||||
URL: params.GeoJSURL,
|
||||
Now: time.Now,
|
||||
ProcessLog: params.ProcessLog,
|
||||
}),
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
||||
// Off is an IdleTimeout of 0, which Go's server replaces with
|
||||
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
|
||||
IdleTimeout: params.Config.ClientIdleTimeout,
|
||||
// Go's server reads 4 KiB past MaxHeaderBytes before it
|
||||
// refuses, so the limit a client meets is the setting.
|
||||
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
|
||||
ErrorLog: errorLog,
|
||||
},
|
||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
||||
IdleTimeout: clientIdleTimeout,
|
||||
MaxHeaderBytes: requestHeaderMaxBytes,
|
||||
ErrorLog: errorLog,
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +129,10 @@ type handler struct {
|
||||
processLog *slog.Logger
|
||||
errorLog *log.Logger
|
||||
transport http.RoundTripper
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
limiter *ratelimit.Limiter
|
||||
ledger *bans.Ledger
|
||||
geojs *lookup.GeoJS
|
||||
}
|
||||
|
||||
@@ -111,7 +150,8 @@ func newTransport() *http.Transport {
|
||||
|
||||
// ServeHTTP handles one request: it works out the client, runs the
|
||||
// checks, passes the request to the app and the answer back within the
|
||||
// limits, and writes the request's log line.
|
||||
// limits, or answers it itself if it is for smallwebwaf, and writes the
|
||||
// request's log line.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.finish()
|
||||
@@ -120,17 +160,33 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// a health checker is never refused. It does not ask the app.
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Once the request has ended, before its log line is written.
|
||||
defer rq.addToHistory()
|
||||
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
|
||||
if refused != nil {
|
||||
rq.answer(*refused)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A request for smallwebwaf itself is answered where another would be
|
||||
// passed to the app, so that it goes through every check first.
|
||||
if strings.HasPrefix(r.URL.Path, adminPrefix) {
|
||||
rq.answerAdmin()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.forward(r.Context())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ const (
|
||||
// localhost is where every test server listens, and so the address
|
||||
// smallwebwaf sees each test's requests come from.
|
||||
localhost = "127.0.0.1"
|
||||
// requestType is the type that marks a request log line.
|
||||
requestType = "request"
|
||||
// protocol is the protocol of every test's requests.
|
||||
protocol = "HTTP/1.1"
|
||||
)
|
||||
|
||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
||||
@@ -45,9 +49,12 @@ var shortTimeoutSetting = shortTimeout.String()
|
||||
// The settings the tests set.
|
||||
const (
|
||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
mode = "SWWAF_MODE"
|
||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
@@ -55,8 +62,17 @@ const (
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
)
|
||||
|
||||
// output collects what smallwebwaf writes on stdout.
|
||||
@@ -73,6 +89,14 @@ func (o *output) Write(p []byte) (int, error) {
|
||||
return o.buf.Write(p)
|
||||
}
|
||||
|
||||
// text returns everything written so far.
|
||||
func (o *output) text() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
return o.buf.String()
|
||||
}
|
||||
|
||||
// lines returns every line written so far, decoded.
|
||||
func (o *output) lines(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
@@ -112,7 +136,7 @@ func (o *output) requestLines(t *testing.T, count int) []logLine {
|
||||
var found []logLine
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] == "request" {
|
||||
if fields["type"] == requestType {
|
||||
found = append(found, decodeLine(t, fields))
|
||||
}
|
||||
}
|
||||
@@ -181,6 +205,19 @@ func startProxyWithGeoJS(
|
||||
) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
addr, out, _ := startProxyWithClock(t, appURL, geojsURL, time.Now, env)
|
||||
|
||||
return addr, out
|
||||
}
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns the server as well.
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *proxy.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
@@ -199,6 +236,7 @@ func startProxyWithGeoJS(
|
||||
RequestLog: out,
|
||||
ProcessLog: requestlog.NewProcessLogger(out),
|
||||
GeoJSURL: geojsURL,
|
||||
Now: now,
|
||||
})
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
@@ -214,7 +252,7 @@ func startProxyWithGeoJS(
|
||||
_ = server.Close()
|
||||
})
|
||||
|
||||
return listener.Addr().String(), out
|
||||
return listener.Addr().String(), out, server
|
||||
}
|
||||
|
||||
// newClient returns an HTTP client that sends requests as they are made,
|
||||
|
||||
@@ -8,7 +8,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
|
||||
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||
// limit_hit names it.
|
||||
const minute = "minute"
|
||||
|
||||
func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
@@ -24,19 +28,19 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
|
||||
const otherClient = "203.0.113.10"
|
||||
|
||||
// With a limit of one request a minute, a client's second request is
|
||||
// refused. A client is one IPv4 address, or one IPv6 /64; an IPv4
|
||||
// address in IPv6 form is that IPv4 address.
|
||||
// refused, with 403 by default. A client is one IPv4 address, or one
|
||||
// IPv6 /64; an IPv4 address in IPv6 form is that IPv4 address.
|
||||
requests := []struct {
|
||||
client string // as X-Forwarded-For names it
|
||||
logged string // as the log line's client_ip names it
|
||||
want int
|
||||
}{
|
||||
{client, client, http.StatusOK},
|
||||
{client, client, http.StatusTooManyRequests},
|
||||
{client, client, http.StatusForbidden},
|
||||
{otherClient, otherClient, http.StatusOK},
|
||||
{"::ffff:" + otherClient, otherClient, http.StatusTooManyRequests},
|
||||
{"::ffff:" + otherClient, otherClient, http.StatusForbidden},
|
||||
{"2001:db8::1", "2001:db8::1", http.StatusOK},
|
||||
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusTooManyRequests},
|
||||
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden},
|
||||
{"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK},
|
||||
}
|
||||
|
||||
@@ -53,9 +57,9 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
|
||||
if sent.want == http.StatusOK {
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
} else {
|
||||
wantLine(t, line, http.StatusTooManyRequests, requestlog.ActionRateLimited)
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
if line.LimitHit != "minute" {
|
||||
if line.LimitHit != minute {
|
||||
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
+237
-69
@@ -8,10 +8,12 @@ import (
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -21,10 +23,13 @@ const flushAfterEachWrite time.Duration = -1
|
||||
|
||||
// refusal is smallwebwaf refusing a request, or refusing to go on with it:
|
||||
// the status the client is answered if the response has not started yet,
|
||||
// and the action the log line names.
|
||||
// 0 to close the connection without an answer, the action the log line
|
||||
// names, and the setting whose size or time limit the request passed, if
|
||||
// that is why.
|
||||
type refusal struct {
|
||||
status int
|
||||
action string
|
||||
limit string
|
||||
}
|
||||
|
||||
// request is one request on its way through smallwebwaf, from the moment
|
||||
@@ -42,7 +47,9 @@ type request struct {
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
start time.Time
|
||||
// upstreamStart is when the request was handed to the app.
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
checked time.Time
|
||||
upstreamStart time.Time
|
||||
// cancel ends the request to the app.
|
||||
cancel context.CancelFunc
|
||||
@@ -52,24 +59,34 @@ type request struct {
|
||||
complete bool
|
||||
|
||||
// mu guards what follows. The timeouts run on goroutines of their
|
||||
// own, and the transport starts and stops them from its own; once
|
||||
// timersStopped is set, none of them acts any more.
|
||||
// own, and the transport starts and stops them, and notes the times
|
||||
// below, from its own; once timersStopped is set, none of the timeouts
|
||||
// acts any more.
|
||||
mu sync.Mutex
|
||||
timersStopped bool
|
||||
clientRequestTimer *time.Timer
|
||||
upstreamRequestTimer *time.Timer
|
||||
upstreamResponseTimer *time.Timer
|
||||
// requestSent is when the app had been sent the whole request.
|
||||
requestSent time.Time
|
||||
// connected is when there was a connection to the app, requestSent
|
||||
// when the app had been sent the whole request, and answerStarted
|
||||
// when the first byte of its answer arrived.
|
||||
connected time.Time
|
||||
requestSent time.Time
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it notes the time and works out the
|
||||
// client.
|
||||
// newRequest starts handling r: it notes the time, counts the request as
|
||||
// under way, works out the client, and starts the log line with what is
|
||||
// known of the request.
|
||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
h.metrics.RequestStarted()
|
||||
|
||||
start := time.Now()
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
|
||||
peerTrusted := isInside(peer, trusted)
|
||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||
client := clientAddress(peer, forwardedFor, trusted)
|
||||
|
||||
rq := &request{
|
||||
h: h,
|
||||
@@ -78,22 +95,37 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
client: client,
|
||||
peer: peer,
|
||||
peerTrusted: isInside(peer, trusted),
|
||||
peerTrusted: peerTrusted,
|
||||
start: start,
|
||||
line: requestlog.Line{
|
||||
Time: requestlog.FormatTime(start),
|
||||
ClientIP: client.String(),
|
||||
PeerIP: peer.String(),
|
||||
Method: r.Method,
|
||||
Host: r.Host,
|
||||
Path: r.URL.EscapedPath(),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
Action: requestlog.ActionForward,
|
||||
Time: requestlog.FormatTime(start),
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, peerTrusted),
|
||||
Host: r.Host,
|
||||
Path: r.URL.EscapedPath(),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, peerTrusted),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: clientGroup(client).String(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||
HasCookie: len(r.Header.Values("Cookie")) > 0,
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
}
|
||||
|
||||
// A length of -1 is a body whose length was not announced.
|
||||
if r.ContentLength > 0 {
|
||||
rq.line.ContentLength = r.ContentLength
|
||||
}
|
||||
|
||||
if r.Body != http.NoBody {
|
||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
||||
}
|
||||
@@ -101,56 +133,94 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
return rq
|
||||
}
|
||||
|
||||
// requestHeaders returns the headers of r that names lists, by name in
|
||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||
// and Set-Cookie are never among them, whatever names says.
|
||||
func requestHeaders(r *http.Request, names []string) map[string]string {
|
||||
headers := map[string]string{}
|
||||
|
||||
for _, name := range names {
|
||||
switch name {
|
||||
case "authorization", "cookie", "set-cookie":
|
||||
continue
|
||||
}
|
||||
|
||||
values := r.Header.Values(name)
|
||||
if len(values) > 0 {
|
||||
headers[name] = strings.Join(values, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// check is the one place where a request can be refused once its client
|
||||
// is known, before its body is read or anything reaches the app. It
|
||||
// returns nil to let the request through. A client in SWWAF_ALLOW_NETS
|
||||
// skips every check but the size limit. For any other client,
|
||||
// SWWAF_DENY_NETS comes first, so that a client it refuses is not looked
|
||||
// up, and then the country lists; a request either refuses is not counted
|
||||
// for the rate limits. Then come the rate limits, unless the client is in
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted,
|
||||
// one refused for its size too. ctx is the request's own context.
|
||||
// returns nil to let the request through. The checks of checkClient come
|
||||
// first, answered with SWWAF_BAN_RESPONSE, and then the size limit, so
|
||||
// that a request the rate limits count is counted even when it is
|
||||
// refused for its size. In observe mode a request checkClient refuses
|
||||
// goes on to the size limit like any other. ctx is the request's own
|
||||
// context.
|
||||
func (rq *request) check(ctx context.Context) *refusal {
|
||||
cfg := rq.h.config
|
||||
allowed := isInside(rq.client, cfg.AllowNets)
|
||||
|
||||
if !allowed && isInside(rq.client, cfg.DenyNets) {
|
||||
return &refusal{
|
||||
status: http.StatusForbidden,
|
||||
action: requestlog.ActionDenied,
|
||||
action := rq.checkClient(ctx)
|
||||
if action != "" {
|
||||
if !rq.h.config.Observe {
|
||||
return rq.banResponse(action)
|
||||
}
|
||||
|
||||
// The log line names what enforce mode would have done.
|
||||
rq.line.WouldAction = action
|
||||
}
|
||||
|
||||
if !allowed && rq.countryDenied(ctx) {
|
||||
return &refusal{
|
||||
status: http.StatusForbidden,
|
||||
action: requestlog.ActionCountryDenied,
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed && !isInside(rq.client, cfg.RateLimitExemptNets) {
|
||||
limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start)
|
||||
if limitHit != "" {
|
||||
rq.line.LimitHit = limitHit
|
||||
|
||||
return &refusal{
|
||||
status: http.StatusTooManyRequests,
|
||||
action: requestlog.ActionRateLimited,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxBytes := cfg.RequestMaxBytes
|
||||
maxBytes := rq.h.config.RequestMaxBytes
|
||||
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
|
||||
return &refusal{
|
||||
status: http.StatusRequestEntityTooLarge,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_REQUEST_MAX_BYTES",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkClient runs the checks on the request's client, and returns the
|
||||
// action of the first that refuses the request, or "" when none does. A
|
||||
// client in SWWAF_ALLOW_NETS skips them. For any other client,
|
||||
// SWWAF_DENY_NETS comes first, then a ban on its netblock, so that a
|
||||
// client either refuses is not looked up, and then the country lists; a
|
||||
// request any of them refuses is not counted for the rate limits. Then
|
||||
// come the rate limits, unless the client is in
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted.
|
||||
// ctx is the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
if isInside(rq.client, cfg.DenyNets) {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
if rq.banned(now) {
|
||||
return requestlog.ActionBanned
|
||||
}
|
||||
|
||||
if rq.countryDenied(ctx) {
|
||||
return requestlog.ActionCountryDenied
|
||||
}
|
||||
|
||||
if !isInside(rq.client, cfg.RateLimitExemptNets) && rq.limitBroken(now) {
|
||||
return requestlog.ActionRateLimited
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// forward passes the request to the app and the app's answer back. ctx
|
||||
// is the request's own context.
|
||||
func (rq *request) forward(ctx context.Context) {
|
||||
@@ -159,7 +229,9 @@ func (rq *request) forward(ctx context.Context) {
|
||||
|
||||
rq.cancel = cancel
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotConn: rq.gotConn,
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||
})
|
||||
|
||||
out := rq.in.WithContext(ctx)
|
||||
@@ -182,7 +254,8 @@ func (rq *request) forward(ctx context.Context) {
|
||||
}
|
||||
|
||||
// rewrite makes the request the app receives: the client's request,
|
||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers set.
|
||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
||||
// the request's id set.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
@@ -191,6 +264,7 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
// the query as the client sent it.
|
||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
||||
}
|
||||
|
||||
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
||||
@@ -204,13 +278,18 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
||||
// connection it takes over, not through rq.out.
|
||||
rq.stopTimers()
|
||||
rq.out.status = res.StatusCode
|
||||
rq.line.Websocket = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
maxBytes := rq.h.config.ResponseMaxBytes
|
||||
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
|
||||
rq.refuse(refusal{status: http.StatusBadGateway, action: requestlog.ActionTooLarge})
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusBadGateway,
|
||||
action: requestlog.ActionTooLarge,
|
||||
limit: "SWWAF_RESPONSE_MAX_BYTES",
|
||||
})
|
||||
|
||||
return errResponseTooLarge
|
||||
}
|
||||
@@ -250,6 +329,13 @@ func (rq *request) answer(r refusal) {
|
||||
return // too late to answer: the connection can only be cut
|
||||
}
|
||||
|
||||
if r.status == 0 {
|
||||
// SWWAF_BAN_RESPONSE is close. This panic has Go's server close
|
||||
// the connection without an answer, and log nothing; the log line
|
||||
// is still written as the handler returns.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// A client found too slow is read no more; any other may go on
|
||||
// sending until its time is up, so that Go's server can read the
|
||||
// rest of the body and end the request cleanly.
|
||||
@@ -275,7 +361,8 @@ func (rq *request) refuse(r refusal) {
|
||||
rq.cancel()
|
||||
}
|
||||
|
||||
// finish ends the request's timeouts and writes its log line.
|
||||
// finish ends the request's timeouts, counts it in the metrics and writes
|
||||
// its log line.
|
||||
func (rq *request) finish() {
|
||||
rq.stopTimers()
|
||||
|
||||
@@ -287,35 +374,90 @@ func (rq *request) finish() {
|
||||
line := &rq.line
|
||||
line.Status = rq.out.status
|
||||
line.ResponseBytes = rq.out.bytes
|
||||
header := rq.out.Header()
|
||||
line.ResponseContentType = header.Get("Content-Type")
|
||||
line.CacheControl = header.Get("Cache-Control")
|
||||
line.Location = header.Get("Location")
|
||||
|
||||
if rq.body != nil {
|
||||
line.RequestBytes = rq.body.bytes.Load()
|
||||
}
|
||||
|
||||
// limit is the setting whose size or time limit the request passed.
|
||||
var limit string
|
||||
|
||||
switch {
|
||||
case refused != nil:
|
||||
line.Action = refused.action
|
||||
limit = refused.limit
|
||||
case errors.Is(rq.out.err, os.ErrDeadlineExceeded):
|
||||
// The client took longer than SWWAF_CLIENT_RESPONSE_TIMEOUT to
|
||||
// take the response.
|
||||
line.Action = requestlog.ActionTimedOut
|
||||
limit = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
case !rq.complete && (rq.out.err != nil || rq.in.Context().Err() != nil):
|
||||
line.Aborted = true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
line.DurationTotal = requestlog.Milliseconds(now.Sub(rq.start))
|
||||
duration := now.Sub(rq.start)
|
||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
||||
line.DurationChecks = timing(rq.start, rq.checked)
|
||||
|
||||
var upstreamDuration time.Duration
|
||||
|
||||
if !rq.upstreamStart.IsZero() {
|
||||
line.DurationUpstreamTotal = requestlog.Milliseconds(now.Sub(rq.upstreamStart))
|
||||
upstreamDuration = now.Sub(rq.upstreamStart)
|
||||
line.DurationUpstreamTotal = new(requestlog.Milliseconds(upstreamDuration))
|
||||
|
||||
rq.mu.Lock()
|
||||
line.DurationUpstreamConnect = timing(rq.upstreamStart, rq.connected)
|
||||
line.DurationUpstreamFirstByte = timing(rq.upstreamStart, rq.answerStarted)
|
||||
rq.mu.Unlock()
|
||||
}
|
||||
|
||||
// Counted before the log line is written, so that the metrics count
|
||||
// every request whose line is out.
|
||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
||||
|
||||
err := requestlog.Write(rq.h.requestLog, line)
|
||||
if err != nil {
|
||||
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// timing is the time from start to end in milliseconds, for one of the
|
||||
// log line's timings, or nil when end is zero: what it times never
|
||||
// happened.
|
||||
func timing(start, end time.Time) *float64 {
|
||||
if end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return new(requestlog.Milliseconds(end.Sub(start)))
|
||||
}
|
||||
|
||||
// addToHistory adds the request, which has ended, to its client's
|
||||
// history.
|
||||
func (rq *request) addToHistory() {
|
||||
var requestBytes int64
|
||||
if rq.body != nil {
|
||||
requestBytes = rq.body.bytes.Load()
|
||||
}
|
||||
|
||||
forwarded := !rq.upstreamStart.IsZero()
|
||||
|
||||
rq.h.limiter.AddToHistory(clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
||||
Country: rq.line.Country,
|
||||
Forwarded: forwarded,
|
||||
Refused: !forwarded && rq.refused.Load() != nil,
|
||||
Status: rq.out.status,
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: rq.out.bytes,
|
||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// clientRequestDeadline is when the client must have sent its whole
|
||||
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off.
|
||||
func (rq *request) clientRequestDeadline() time.Time {
|
||||
@@ -348,21 +490,26 @@ func (rq *request) startRequestTimers() {
|
||||
|
||||
if rq.body != nil && rq.h.config.ClientRequestTimeout > 0 {
|
||||
rq.clientRequestTimer = time.AfterFunc(
|
||||
time.Until(rq.clientRequestDeadline()), rq.requestTimedOut)
|
||||
time.Until(rq.clientRequestDeadline()), func() {
|
||||
rq.requestTimedOut("SWWAF_CLIENT_REQUEST_TIMEOUT")
|
||||
})
|
||||
}
|
||||
|
||||
timeout := rq.h.config.UpstreamRequestTimeout
|
||||
if timeout > 0 {
|
||||
rq.upstreamRequestTimer = time.AfterFunc(timeout, rq.requestTimedOut)
|
||||
rq.upstreamRequestTimer = time.AfterFunc(timeout, func() {
|
||||
rq.requestTimedOut("SWWAF_UPSTREAM_REQUEST_TIMEOUT")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requestTimedOut is called when a request timeout runs out while the
|
||||
// request is still on its way to the app. The answer names the side
|
||||
// smallwebwaf was waiting on at that moment: 408 when it was waiting for
|
||||
// the client to send more of its body, 504 when it was waiting for the
|
||||
// app to be reached or to take what it had.
|
||||
func (rq *request) requestTimedOut() {
|
||||
// requestTimedOut is called when limit, SWWAF_CLIENT_REQUEST_TIMEOUT or
|
||||
// SWWAF_UPSTREAM_REQUEST_TIMEOUT, runs out while the request is still on
|
||||
// its way to the app. The answer names the side smallwebwaf was waiting
|
||||
// on at that moment: 408 when it was waiting for the client to send more
|
||||
// of its body, 504 when it was waiting for the app to be reached or to
|
||||
// take what it had.
|
||||
func (rq *request) requestTimedOut(limit string) {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
@@ -374,6 +521,7 @@ func (rq *request) requestTimedOut() {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusGatewayTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: limit,
|
||||
})
|
||||
|
||||
return
|
||||
@@ -382,6 +530,7 @@ func (rq *request) requestTimedOut() {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusRequestTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: limit,
|
||||
})
|
||||
// The transport gives up on the app only once its Read of the
|
||||
// client's body returns, so that Read is ended now. The lock keeps
|
||||
@@ -397,6 +546,24 @@ func (rq *request) bodyReceived() {
|
||||
stopTimer(rq.clientRequestTimer)
|
||||
}
|
||||
|
||||
// gotConn is called once there is a connection to the app, a new one or
|
||||
// one kept open from an earlier request.
|
||||
func (rq *request) gotConn(httptrace.GotConnInfo) {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.connected = time.Now()
|
||||
}
|
||||
|
||||
// gotFirstResponseByte is called once the first byte of the app's answer
|
||||
// has arrived.
|
||||
func (rq *request) gotFirstResponseByte() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.answerStarted = time.Now()
|
||||
}
|
||||
|
||||
// wroteRequest is called once the app has been sent the whole request:
|
||||
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||
@@ -431,6 +598,7 @@ func (rq *request) responseTimedOut() {
|
||||
rq.refuse(refusal{
|
||||
status: http.StatusGatewayTimeout,
|
||||
action: requestlog.ActionTimedOut,
|
||||
limit: "SWWAF_UPSTREAM_RESPONSE_TIMEOUT",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestIDHeader carries the request's id.
|
||||
requestIDHeader = "X-Request-ID"
|
||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
||||
instance = "fsn1app1/gitea"
|
||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||
// limits count it as.
|
||||
ipv6Client = "2001:db8::7"
|
||||
ipv6Group = "2001:db8::/64"
|
||||
)
|
||||
|
||||
func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2) // the request ids the app received
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
if r.URL.Path != "/full" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Location", "/elsewhere")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
_, _ = io.WriteString(w, "moved")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
rateLimitExemptNets: localhost,
|
||||
instanceName: instance,
|
||||
logRequestHeaders: "Accept,x-custom,Authorization,cookie,SET-COOKIE",
|
||||
})
|
||||
|
||||
// This request comes from ipv6Client through a trusted proxy, with a
|
||||
// body and each header the log line looks at, and is answered with a
|
||||
// redirect.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /full HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
forwardedFor+": 198.51.100.7, "+ipv6Client+"\r\n"+
|
||||
forwardedProto+": "+secure+"\r\n"+requestIDHeader+": from-traefik\r\n"+
|
||||
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3\r\n"+
|
||||
"Accept: text/html\r\nX-Custom: one\r\nX-Custom: two\r\n"+
|
||||
"Authorization: Bearer secret-token\r\nCookie: session=secret-cookie\r\n"+
|
||||
"Set-Cookie: secret-set-cookie\r\n\r\na=b")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusFound)
|
||||
|
||||
// A request's log line can come after its answer: each is waited for
|
||||
// before the next request, so that the lines are in order.
|
||||
full := out.requestLines(t, 1)[0]
|
||||
|
||||
// This one comes from 127.0.0.1, which the rate limits do not count,
|
||||
// with a body of 4 bytes whose length it does not announce, so that its
|
||||
// request_bytes is not its content_length, and no header the log line
|
||||
// looks at, and is answered with 204 and no header.
|
||||
conn = dial(t, addr)
|
||||
send(t, conn, "POST /bare HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||
|
||||
bare := out.requestLines(t, 2)[1]
|
||||
|
||||
wantFullLine(t, full)
|
||||
wantBareLine(t, bare)
|
||||
|
||||
for _, line := range []logLine{full, bare} {
|
||||
got := <-received
|
||||
if got != line.RequestID {
|
||||
t.Errorf("the app received request id %q, the log line has %q",
|
||||
got, line.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), "secret") {
|
||||
t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
|
||||
out.text())
|
||||
}
|
||||
}
|
||||
|
||||
// wantFullLine checks the log line of the request with every header the
|
||||
// line looks at. Its timings are checked by TestTimingsAreInOrder.
|
||||
func wantFullLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||
Host: appHost, Path: "/full", Protocol: protocol,
|
||||
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||
RequestID: "from-traefik", PeerIP: localhost,
|
||||
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
||||
CacheControl: "no-store", Location: "/elsewhere",
|
||||
Action: requestlog.ActionForward,
|
||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBareLine checks the log line of the request with none of them, and
|
||||
// that the fields that do not apply to it are left out.
|
||||
func wantBareLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward,
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"cache_control", "location", "counts",
|
||||
} {
|
||||
_, present := line.fields[name]
|
||||
if present {
|
||||
t.Errorf("log line has %s, which does not apply", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withTimings returns want with the timings of line.
|
||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||
want.DurationTotal = line.DurationTotal
|
||||
want.DurationChecks = line.DurationChecks
|
||||
want.DurationUpstreamConnect = line.DurationUpstreamConnect
|
||||
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
|
||||
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
||||
|
||||
return want
|
||||
}
|
||||
|
||||
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const hasAuthorization, hasCookie = "has_authorization", "has_cookie"
|
||||
|
||||
for _, tc := range []struct{ header, field, other string }{
|
||||
{"Authorization", hasAuthorization, hasCookie},
|
||||
{"Cookie", hasCookie, hasAuthorization},
|
||||
} {
|
||||
t.Run("only "+tc.header, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(tc.header, "secret")
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLine(t)
|
||||
|
||||
_, otherPresent := line.fields[tc.other]
|
||||
if line.fields[tc.field] != true || otherPresent {
|
||||
t.Errorf("log line has %s %v and %s %v, want true and none",
|
||||
tc.field, line.fields[tc.field], tc.other, line.fields[tc.other])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDAndSchemeComeOnlyFromATrustedProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sentID = "from-traefik"
|
||||
|
||||
sent := http.Header{requestIDHeader: {sentID}, forwardedProto: {secure}}
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
header http.Header
|
||||
// wantID is the request id logged, "" for a new one.
|
||||
wantID, wantScheme string
|
||||
}{
|
||||
{"a trusted proxy's are kept", trusted, sent, sentID, secure},
|
||||
{"without them, the id is new and the scheme http", trusted, nil, "", plain},
|
||||
{"another peer's are replaced", nil, sent, "", plain},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, tc.env)
|
||||
|
||||
// Two requests, so that two new ids can be told apart.
|
||||
ids := make([]string, 0, 2)
|
||||
|
||||
for i := range 2 {
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
maps.Copy(req.Header, tc.header)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
ids = append(ids, line.RequestID)
|
||||
|
||||
got := <-received
|
||||
if line.RequestID != got || line.Scheme != tc.wantScheme {
|
||||
t.Errorf("log line has request_id %q and scheme %q, and the "+
|
||||
"app received id %q; want the same id and scheme %q",
|
||||
line.RequestID, line.Scheme, got, tc.wantScheme)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case tc.wantID != "" && (ids[0] != tc.wantID || ids[1] != tc.wantID):
|
||||
t.Errorf("request ids %q, want %q", ids, tc.wantID)
|
||||
case tc.wantID == "" && (slices.Contains(ids, sentID) ||
|
||||
slices.Contains(ids, "") || ids[0] == ids[1]):
|
||||
t.Errorf("request ids %q, want two new ones", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingsAreInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
// The pauses set the times apart; a hold-up of the test only
|
||||
// lengthens them.
|
||||
time.Sleep(time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
_, _ = io.WriteString(w, "done")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
})
|
||||
|
||||
// Each log line is waited for before the next request, so that the
|
||||
// lines are in order.
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||
forwarded := out.requestLines(t, 1)[0]
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
refused := out.requestLines(t, 2)[1]
|
||||
|
||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||
health := out.requestLines(t, 3)[2]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
// the app's; the health check, which runs no check, only the total.
|
||||
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total")
|
||||
wantTimings(t, refused, "duration_total", "duration_checks")
|
||||
wantTimings(t, health, "duration_total")
|
||||
|
||||
if t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
// In whole microseconds, as they are logged, so that the sum below is
|
||||
// exact.
|
||||
total := microseconds(forwarded.DurationTotal)
|
||||
checks := microseconds(*forwarded.DurationChecks)
|
||||
connect := microseconds(*forwarded.DurationUpstreamConnect)
|
||||
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
|
||||
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
||||
|
||||
// The checks end before the request is handed to the app, and the
|
||||
// connection comes before the answer, which the app ends after a
|
||||
// pause.
|
||||
if checks+upstream > total || connect >= firstByte || firstByte >= upstream {
|
||||
t.Errorf("timings in microseconds: total %d, checks %d, connect %d, "+
|
||||
"first byte %d, upstream total %d", total, checks, connect, firstByte,
|
||||
upstream)
|
||||
}
|
||||
|
||||
if *refused.DurationChecks > refused.DurationTotal {
|
||||
t.Errorf("refused request's checks took %v of %v milliseconds",
|
||||
*refused.DurationChecks, refused.DurationTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// wantTimings checks that the timings named are the only ones line has.
|
||||
func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var got []string
|
||||
|
||||
for name := range line.fields {
|
||||
if strings.HasPrefix(name, "duration_") {
|
||||
got = append(got, name)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// microseconds is a timing in whole microseconds.
|
||||
func microseconds(milliseconds float64) int64 {
|
||||
return int64(math.Round(milliseconds * 1000))
|
||||
}
|
||||
|
||||
func TestLogsAnUpgradedConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line.fields["websocket"] != true {
|
||||
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
|
||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
||||
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
||||
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
|
||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||
{unlistedAddr, http.StatusTooManyRequests, requestlog.ActionRateLimited},
|
||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ func TestRequestTimeouts(t *testing.T) {
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// limit is the setting set to shortTimeout, which runs out; long
|
||||
// is one set to longTimeoutSetting, which does not, or "".
|
||||
limit, long string
|
||||
// appTakesNothing has the app never read, while the client sends
|
||||
// as fast as it can; otherwise the app reads, and the client
|
||||
// stops sending halfway.
|
||||
@@ -36,30 +38,26 @@ func TestRequestTimeouts(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "client request timeout, waiting on the client",
|
||||
env: map[string]string{clientRequestTimeout: shortTimeoutSetting},
|
||||
want: http.StatusRequestTimeout,
|
||||
name: "client request timeout, waiting on the client",
|
||||
limit: clientRequestTimeout,
|
||||
want: http.StatusRequestTimeout,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, waiting on the client",
|
||||
env: map[string]string{
|
||||
upstreamRequestTimeout: shortTimeoutSetting,
|
||||
clientRequestTimeout: longTimeoutSetting,
|
||||
},
|
||||
want: http.StatusRequestTimeout,
|
||||
name: "upstream request timeout, waiting on the client",
|
||||
limit: upstreamRequestTimeout,
|
||||
long: clientRequestTimeout,
|
||||
want: http.StatusRequestTimeout,
|
||||
},
|
||||
{
|
||||
name: "upstream request timeout, waiting on the app",
|
||||
env: map[string]string{upstreamRequestTimeout: shortTimeoutSetting},
|
||||
limit: upstreamRequestTimeout,
|
||||
appTakesNothing: true,
|
||||
want: http.StatusGatewayTimeout,
|
||||
},
|
||||
{
|
||||
name: "client request timeout, waiting on the app",
|
||||
env: map[string]string{
|
||||
clientRequestTimeout: shortTimeoutSetting,
|
||||
upstreamRequestTimeout: longTimeoutSetting,
|
||||
},
|
||||
name: "client request timeout, waiting on the app",
|
||||
limit: clientRequestTimeout,
|
||||
long: upstreamRequestTimeout,
|
||||
appTakesNothing: true,
|
||||
want: http.StatusGatewayTimeout,
|
||||
},
|
||||
@@ -84,7 +82,12 @@ func TestRequestTimeouts(t *testing.T) {
|
||||
appURL, sendRequest = app.URL, sendPartOfBody
|
||||
}
|
||||
|
||||
addr, out := startProxy(t, appURL, tc.env)
|
||||
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
|
||||
if tc.long != "" {
|
||||
env[tc.long] = longTimeoutSetting
|
||||
}
|
||||
|
||||
addr, out := startProxy(t, appURL, env)
|
||||
start := time.Now()
|
||||
got := readResponse(t, sendRequest(t, addr))
|
||||
wantTimedOut(t, start)
|
||||
@@ -105,6 +108,7 @@ func TestRequestTimeouts(t *testing.T) {
|
||||
|
||||
wantStatus(t, got, want)
|
||||
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, tc.limit, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -198,6 +202,7 @@ func TestAppTooSlowToAnswer(t *testing.T) {
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
upstreamResponseTimeout: shortTimeoutSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
@@ -213,6 +218,8 @@ func TestAppTooSlowToAnswer(t *testing.T) {
|
||||
t.Errorf("log line has upstream_status %v for an app that never answered",
|
||||
line.fields["upstream_status"])
|
||||
}
|
||||
|
||||
wantLimitHits(t, addr, upstreamResponseTimeout, 1)
|
||||
}
|
||||
|
||||
func TestAppTooSlowToFinishItsAnswer(t *testing.T) {
|
||||
@@ -261,6 +268,7 @@ func TestClientTooSlowToTakeTheAnswer(t *testing.T) {
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
clientResponseTimeout: shortTimeoutSetting,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
@@ -272,4 +280,28 @@ func TestClientTooSlowToTakeTheAnswer(t *testing.T) {
|
||||
line := out.requestLine(t)
|
||||
wantTimedOut(t, start)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
|
||||
wantLimitHits(t, addr, clientResponseTimeout, 1)
|
||||
}
|
||||
|
||||
func TestClosesAnIdleConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _ := startProxy(t, app.URL, map[string]string{
|
||||
clientIdleTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
// The idle time starts once the answer is sent, so after start.
|
||||
start := time.Now()
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: app\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusOK)
|
||||
|
||||
// The read deadline readResponse set still bounds this read.
|
||||
_, err := conn.Read(make([]byte, 1))
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("read on the idle connection: %v, want it closed", err)
|
||||
}
|
||||
|
||||
wantTimedOut(t, start)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package ratelimit_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsEveryRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
for i, r := range []ratelimit.Request{
|
||||
{Country: "DE", Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
||||
{Forwarded: true, Status: 101},
|
||||
{Forwarded: true, Status: 304, RequestBytes: 5},
|
||||
{Country: "FR", Refused: true, Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||
// Closed without an answer: refused, and no response.
|
||||
{Refused: true, Status: 0},
|
||||
// Answered 404 at smallwebwaf's own endpoints: neither forwarded
|
||||
// nor refused.
|
||||
{Status: 404},
|
||||
} {
|
||||
limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
|
||||
}
|
||||
|
||||
want := ratelimit.History{
|
||||
FirstSeen: start,
|
||||
LastSeen: start.Add(6 * time.Minute),
|
||||
Country: "FR",
|
||||
LookedUp: start.Add(3 * time.Minute),
|
||||
Requests: 7,
|
||||
Forwarded: 4,
|
||||
Refused: 2,
|
||||
RequestBytes: 15,
|
||||
ResponseBytes: 122,
|
||||
Responses: ratelimit.Responses{
|
||||
Status1xx: 1, Status2xx: 1, Status3xx: 1, Status4xx: 2, Status5xx: 1,
|
||||
},
|
||||
Offences: ratelimit.Offences{Limit: 1},
|
||||
}
|
||||
|
||||
got := historyOf(t, limiter, client)
|
||||
if got != want {
|
||||
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetKeepsTheHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
for range limit {
|
||||
wantCount(t, limiter, client, start, "")
|
||||
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||
}
|
||||
|
||||
limiter.Reset(client)
|
||||
|
||||
if got := historyOf(t, limiter, client).Requests; got != limit {
|
||||
t.Errorf("the history counts %d requests, want %d", got, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsAddsUpTheClientsInsideTheNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
|
||||
for client, requests := range map[string]int{
|
||||
"198.51.100.9/32": 2,
|
||||
"198.51.100.10/32": 3,
|
||||
"192.0.2.1/32": 5,
|
||||
"2001:db8:5::/64": 7,
|
||||
} {
|
||||
for range requests {
|
||||
limiter.AddToHistory(netip.MustParsePrefix(client), midnight(),
|
||||
ratelimit.Request{})
|
||||
}
|
||||
}
|
||||
|
||||
for netblock, want := range map[string]int64{
|
||||
"198.51.100.9/32": 2,
|
||||
"198.51.100.0/24": 5,
|
||||
"2001:db8:5::/64": 7,
|
||||
"203.0.113.0/24": 0,
|
||||
} {
|
||||
got := limiter.Requests(netip.MustParsePrefix(netblock))
|
||||
if got != want {
|
||||
t.Errorf("%s has sent %d requests, want %d", netblock, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// historyOf returns client's history.
|
||||
func historyOf(
|
||||
t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix,
|
||||
) ratelimit.History {
|
||||
t.Helper()
|
||||
|
||||
for _, c := range limiter.Snapshot() {
|
||||
if c.Client == client {
|
||||
return c.History
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("%s is not in the table", client)
|
||||
|
||||
return ratelimit.History{}
|
||||
}
|
||||
+304
-47
@@ -1,11 +1,15 @@
|
||||
// Package ratelimit counts each client's requests over a minute, an hour
|
||||
// and a day, as the "Counting method" section of SPEC.md describes, and
|
||||
// tells when a request takes a client over a rate limit. The counts are
|
||||
// kept in memory only, for at most 20,000 clients.
|
||||
// Package ratelimit keeps the table of clients: each client's requests
|
||||
// counted over a minute, an hour and a day, as the "Counting method"
|
||||
// section of SPEC.md describes, which tell when a request takes the client
|
||||
// over a rate limit, and each client's history since it was first seen.
|
||||
// At most 20,000 clients are kept, in memory, and written to clients.json
|
||||
// and read from it by the state package.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +17,8 @@ import (
|
||||
)
|
||||
|
||||
// maxClients is how many clients are kept. Past it, the least recently
|
||||
// seen client is dropped, and starts afresh if it comes back.
|
||||
// seen client is dropped, with its history, and starts afresh if it comes
|
||||
// back.
|
||||
const maxClients = 20000
|
||||
|
||||
const day = 24 * time.Hour
|
||||
@@ -26,20 +31,99 @@ type Limits struct {
|
||||
PerDay int64
|
||||
}
|
||||
|
||||
// Limiter counts each client's requests against the limits. It is safe
|
||||
// for concurrent use.
|
||||
// Limiter counts each client's requests against the limits, and keeps
|
||||
// its history. It is safe for concurrent use.
|
||||
type Limiter struct {
|
||||
// windows are the minute, the hour and the day, in the order of
|
||||
// Client.buckets.
|
||||
windows [3]window
|
||||
|
||||
mu sync.Mutex
|
||||
// clients holds each client's buckets, one pair for each of windows,
|
||||
// in the same order.
|
||||
clients *simplelru.LRU[netip.Prefix, *[3]buckets]
|
||||
mu sync.Mutex
|
||||
clients *simplelru.LRU[netip.Prefix, *Client]
|
||||
}
|
||||
|
||||
// Client is a client in the table, as clients.json holds it: its buckets
|
||||
// in each window, and its history.
|
||||
type Client struct {
|
||||
Client netip.Prefix `json:"client"`
|
||||
Minute Buckets `json:"minute"`
|
||||
Hour Buckets `json:"hour"`
|
||||
Day Buckets `json:"day"`
|
||||
History History `json:"history"`
|
||||
}
|
||||
|
||||
// Buckets are a client's two buckets in one window: the requests in the
|
||||
// bucket under way, which began at Start, and in the bucket before it.
|
||||
type Buckets struct {
|
||||
Start time.Time `json:"start"`
|
||||
Current int64 `json:"current"`
|
||||
Previous int64 `json:"previous"`
|
||||
}
|
||||
|
||||
// History is what is known of a client since it was first seen.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type History struct {
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// Country is the client's country as it was last looked up, and
|
||||
// LookedUp when that was; both are empty while it never was.
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those passed to
|
||||
// the app, Refused those refused before anything reached it, a 401 at
|
||||
// smallwebwaf's own endpoints included, and neither the others
|
||||
// smallwebwaf answered there.
|
||||
Requests int64 `json:"requests"`
|
||||
Forwarded int64 `json:"forwarded"`
|
||||
Refused int64 `json:"refused"`
|
||||
// RequestBytes and ResponseBytes are the body bytes of its requests
|
||||
// and of the responses it was sent.
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
Responses Responses `json:"responses,omitzero"`
|
||||
Offences Offences `json:"offences,omitzero"`
|
||||
}
|
||||
|
||||
// Responses are the responses a client was sent, by status class;
|
||||
// Status5xx counts every status from 500 up.
|
||||
type Responses struct {
|
||||
Status1xx int64 `json:"1xx,omitempty"`
|
||||
Status2xx int64 `json:"2xx,omitempty"`
|
||||
Status3xx int64 `json:"3xx,omitempty"`
|
||||
Status4xx int64 `json:"4xx,omitempty"`
|
||||
Status5xx int64 `json:"5xx,omitempty"`
|
||||
}
|
||||
|
||||
// Offences are a client's offences, by kind.
|
||||
type Offences struct {
|
||||
// Limit is its requests that broke a rate limit.
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Country is the client's country, when the request looked it up.
|
||||
Country string
|
||||
// Forwarded is true for a request passed to the app, Refused for one
|
||||
// refused before anything reached it, a 401 at smallwebwaf's own
|
||||
// endpoints included. Both are false for any other request smallwebwaf
|
||||
// answered there.
|
||||
Forwarded bool
|
||||
Refused bool
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
Status int
|
||||
// RequestBytes and ResponseBytes are the body bytes of the request
|
||||
// and of its response.
|
||||
RequestBytes int64
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit.
|
||||
BrokeLimit bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet.
|
||||
func New(limits Limits) *Limiter {
|
||||
clients, err := simplelru.NewLRU[netip.Prefix, *[3]buckets](maxClients, nil)
|
||||
clients, err := simplelru.NewLRU[netip.Prefix, *Client](maxClients, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
@@ -54,30 +138,194 @@ func New(limits Limits) *Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Hit is a request that takes a client over a rate limit.
|
||||
type Hit struct {
|
||||
// Window is "minute", "hour" or "day".
|
||||
Window string
|
||||
// Limit is the window's limit.
|
||||
Limit int64
|
||||
// Requests is the client's requests counted in the window, this one
|
||||
// included.
|
||||
Requests float64
|
||||
}
|
||||
|
||||
// Counts are a client's requests in the minute, the hour and the day that
|
||||
// end at a request, that request included.
|
||||
type Counts struct {
|
||||
Minute float64 `json:"minute"`
|
||||
Hour float64 `json:"hour"`
|
||||
Day float64 `json:"day"`
|
||||
}
|
||||
|
||||
// Count counts a request from client at now, in every window, whether or
|
||||
// not it is refused. It returns the window whose limit the request takes
|
||||
// the client over, "minute", "hour" or "day", the shortest if it is over
|
||||
// several, or "" if it is within every limit.
|
||||
func (l *Limiter) Count(client netip.Prefix, now time.Time) string {
|
||||
// not it is refused, and returns the client's requests in each window. It
|
||||
// reports whether the request takes the client over a limit, and the
|
||||
// window whose limit it goes over, the shortest if it is over several.
|
||||
func (l *Limiter) Count(client netip.Prefix, now time.Time) (Counts, Hit, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
counts, seen := l.clients.Get(client)
|
||||
if !seen {
|
||||
counts = &[3]buckets{}
|
||||
l.clients.Add(client, counts)
|
||||
}
|
||||
var (
|
||||
requests [3]float64
|
||||
hit Hit
|
||||
)
|
||||
|
||||
limitHit := ""
|
||||
for i, b := range l.get(client).buckets() {
|
||||
w := l.windows[i]
|
||||
|
||||
for i, w := range l.windows {
|
||||
requests := counts[i].add(now, w.length)
|
||||
if limitHit == "" && w.limit > 0 && requests > float64(w.limit) {
|
||||
limitHit = w.name
|
||||
requests[i] = b.add(now, w.length)
|
||||
if hit.Window == "" && w.limit > 0 && requests[i] > float64(w.limit) {
|
||||
hit = Hit{Window: w.name, Limit: w.limit, Requests: requests[i]}
|
||||
}
|
||||
}
|
||||
|
||||
return limitHit
|
||||
counts := Counts{Minute: requests[0], Hour: requests[1], Day: requests[2]}
|
||||
|
||||
return counts, hit, hit.Window != ""
|
||||
}
|
||||
|
||||
// Reset sets client's counts in every window back to zero. Its history
|
||||
// keeps its totals.
|
||||
func (l *Limiter) Reset(client netip.Prefix) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
c, seen := l.clients.Peek(client)
|
||||
if seen {
|
||||
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
|
||||
}
|
||||
}
|
||||
|
||||
// AddToHistory adds r, a request from client at now, to the client's
|
||||
// history.
|
||||
func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
h := &l.get(client).History
|
||||
if h.FirstSeen.IsZero() {
|
||||
h.FirstSeen = now
|
||||
}
|
||||
|
||||
h.LastSeen = now
|
||||
|
||||
if r.Country != "" {
|
||||
h.Country = r.Country
|
||||
h.LookedUp = now
|
||||
}
|
||||
|
||||
h.Requests++
|
||||
if r.Forwarded {
|
||||
h.Forwarded++
|
||||
}
|
||||
|
||||
if r.Refused {
|
||||
h.Refused++
|
||||
}
|
||||
|
||||
h.RequestBytes += r.RequestBytes
|
||||
h.ResponseBytes += r.ResponseBytes
|
||||
h.Responses.add(r.Status)
|
||||
|
||||
if r.BrokeLimit {
|
||||
h.Offences.Limit++
|
||||
}
|
||||
}
|
||||
|
||||
// Requests returns how many requests the clients inside netblock have
|
||||
// sent, as their histories count them.
|
||||
func (l *Limiter) Requests(netblock netip.Prefix) int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Most often the netblock is one client.
|
||||
c, seen := l.clients.Peek(netblock)
|
||||
if seen {
|
||||
return c.History.Requests
|
||||
}
|
||||
|
||||
var requests int64
|
||||
|
||||
for _, c := range l.clients.Values() {
|
||||
if netblock.Overlaps(c.Client) {
|
||||
requests += c.History.Requests
|
||||
}
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// Len returns how many clients are in the table.
|
||||
func (l *Limiter) Len() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.clients.Len()
|
||||
}
|
||||
|
||||
// Snapshot returns every client in the table, sorted by address, as
|
||||
// clients.json lists them.
|
||||
func (l *Limiter) Snapshot() []Client {
|
||||
l.mu.Lock()
|
||||
|
||||
clients := make([]Client, 0, l.clients.Len())
|
||||
for _, c := range l.clients.Values() {
|
||||
clients = append(clients, *c)
|
||||
}
|
||||
|
||||
l.mu.Unlock()
|
||||
|
||||
slices.SortFunc(clients, func(a, b Client) int {
|
||||
return a.Client.Compare(b.Client)
|
||||
})
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// Load puts clients read from clients.json into the table, in place of
|
||||
// the clients it holds, in the order they were last seen, so that the
|
||||
// least recently seen is dropped first. Buckets whose time has passed at
|
||||
// now are emptied.
|
||||
func (l *Limiter) Load(clients []Client, now time.Time) {
|
||||
clients = slices.Clone(clients)
|
||||
slices.SortStableFunc(clients, func(a, b Client) int {
|
||||
return a.History.LastSeen.Compare(b.History.LastSeen)
|
||||
})
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.clients.Purge()
|
||||
|
||||
for _, c := range clients {
|
||||
for i, b := range c.buckets() {
|
||||
// The window that ends at now covers neither bucket once it
|
||||
// begins after the bucket under way has ended.
|
||||
length := l.windows[i].length
|
||||
if !now.Add(-length).Before(b.Start.Add(length)) {
|
||||
*b = Buckets{}
|
||||
}
|
||||
}
|
||||
|
||||
l.clients.Add(c.Client, &c)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns client's entry in the table, a new one if it has none, and
|
||||
// makes it the most recently seen.
|
||||
func (l *Limiter) get(client netip.Prefix) *Client {
|
||||
c, seen := l.clients.Get(client)
|
||||
if !seen {
|
||||
c = &Client{Client: client}
|
||||
l.clients.Add(client, c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// buckets returns c's buckets in the minute, the hour and the day.
|
||||
func (c *Client) buckets() [3]*Buckets {
|
||||
return [3]*Buckets{&c.Minute, &c.Hour, &c.Day}
|
||||
}
|
||||
|
||||
// window is a length of time over which requests are counted, and the
|
||||
@@ -88,14 +336,6 @@ type window struct {
|
||||
limit int64
|
||||
}
|
||||
|
||||
// buckets are a client's two buckets in one window: the requests in the
|
||||
// bucket under way, which began at start, and in the bucket before it.
|
||||
type buckets struct {
|
||||
start time.Time
|
||||
current int64
|
||||
previous int64
|
||||
}
|
||||
|
||||
// add counts a request at now in a window of length, and returns the
|
||||
// client's requests in the window that ends at now: those in the bucket
|
||||
// under way, and those in the bucket before it weighted by how much of
|
||||
@@ -106,27 +346,44 @@ type buckets struct {
|
||||
// bucket. A request dated more than a second before it means the clock
|
||||
// was set back, and the buckets start afresh: otherwise the bucket before
|
||||
// would keep its full weight until the clock caught up.
|
||||
func (b *buckets) add(now time.Time, length time.Duration) float64 {
|
||||
if now.Before(b.start.Add(-time.Second)) {
|
||||
*b = buckets{}
|
||||
func (b *Buckets) add(now time.Time, length time.Duration) float64 {
|
||||
if now.Before(b.Start.Add(-time.Second)) {
|
||||
*b = Buckets{}
|
||||
}
|
||||
|
||||
start := now.Truncate(length)
|
||||
if start.After(b.start) {
|
||||
if start.Equal(b.start.Add(length)) {
|
||||
b.previous = b.current
|
||||
if start.After(b.Start) {
|
||||
if start.Equal(b.Start.Add(length)) {
|
||||
b.Previous = b.Current
|
||||
} else {
|
||||
b.previous = 0
|
||||
b.Previous = 0
|
||||
}
|
||||
|
||||
b.start = start
|
||||
b.current = 0
|
||||
b.Start = start
|
||||
b.Current = 0
|
||||
}
|
||||
|
||||
b.current++
|
||||
b.Current++
|
||||
|
||||
elapsed := max(now.Sub(b.start), 0)
|
||||
elapsed := max(now.Sub(b.Start), 0)
|
||||
covered := 1 - float64(elapsed)/float64(length)
|
||||
|
||||
return float64(b.previous)*covered + float64(b.current)
|
||||
return float64(b.Previous)*covered + float64(b.Current)
|
||||
}
|
||||
|
||||
// add counts a response with status in its class. A status of 0, for
|
||||
// nothing sent, is not a response.
|
||||
func (r *Responses) add(status int) {
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
r.Status5xx++
|
||||
case status >= http.StatusBadRequest:
|
||||
r.Status4xx++
|
||||
case status >= http.StatusMultipleChoices:
|
||||
r.Status3xx++
|
||||
case status >= http.StatusOK:
|
||||
r.Status2xx++
|
||||
case status >= http.StatusContinue:
|
||||
r.Status1xx++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,75 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: limit})
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
for range limit {
|
||||
_, _, over := limiter.Count(client, start)
|
||||
if over {
|
||||
t.Fatal("a request within the limit is over it")
|
||||
}
|
||||
}
|
||||
|
||||
// Over both limits; the minute's is named, with the four requests.
|
||||
_, hit, over := limiter.Count(client, start)
|
||||
|
||||
want := ratelimit.Hit{Window: minute, Limit: limit, Requests: limit + 1}
|
||||
if !over || hit != want {
|
||||
t.Errorf("request over the limit gives %+v and %t, want %+v and true",
|
||||
hit, over, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
for range 3 {
|
||||
limiter.Count(client, start)
|
||||
}
|
||||
|
||||
// A quarter into the next hour, the minute has only this request. The
|
||||
// hour still covers three quarters of the bucket before, with its three
|
||||
// requests, which count 2.25, and this one: 3.25. The day covers all
|
||||
// four.
|
||||
counts, _, _ := limiter.Count(client, start.Add(time.Hour+time.Hour/4))
|
||||
|
||||
want := ratelimit.Counts{Minute: 1, Hour: 3.25, Day: 4}
|
||||
if counts != want {
|
||||
t.Errorf("counts %+v, want %+v", counts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetSetsTheCountsBackToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit})
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
for range limit {
|
||||
wantCount(t, limiter, client, start, "")
|
||||
}
|
||||
|
||||
wantCount(t, limiter, client, start, minute)
|
||||
limiter.Reset(client)
|
||||
|
||||
// At the same moment, the client has its whole allowance again.
|
||||
for range limit {
|
||||
wantCount(t, limiter, client, start, "")
|
||||
}
|
||||
|
||||
wantCount(t, limiter, client, start, minute)
|
||||
}
|
||||
|
||||
func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -192,9 +261,9 @@ func wantCount(
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
got := limiter.Count(client, now)
|
||||
if got != want {
|
||||
_, hit, _ := limiter.Count(client, now)
|
||||
if hit.Window != want {
|
||||
t.Errorf("request from %s at %s is over %q, want %q",
|
||||
client, now.Format(time.RFC3339), got, want)
|
||||
client, now.Format(time.RFC3339), hit.Window, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package ratelimit_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
func TestSnapshotListsTheClientsByAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := []string{"192.0.2.1/32", "203.0.113.9/32", "203.0.113.10/32", "2001:db8::/64"}
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
for _, i := range []int{2, 3, 0, 1} {
|
||||
limiter.Count(netip.MustParsePrefix(want[i]), midnight())
|
||||
}
|
||||
|
||||
snapshot := limiter.Snapshot()
|
||||
|
||||
got := make([]string, 0, len(snapshot))
|
||||
for _, c := range snapshot {
|
||||
got = append(got, c.Client.String())
|
||||
}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("snapshot %v, want %v", got, want)
|
||||
}
|
||||
|
||||
counted := ratelimit.Buckets{Start: midnight(), Current: 1}
|
||||
if snapshot[0].Minute != counted || snapshot[0].Day != counted {
|
||||
t.Errorf("buckets %+v and %+v, want %+v", snapshot[0].Minute, snapshot[0].Day,
|
||||
counted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedCountsCarryOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
before := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||
for range limit {
|
||||
wantCount(t, before, client, start, "")
|
||||
}
|
||||
|
||||
// Loaded into a new limiter, as across a restart, the client has no
|
||||
// fresh allowance.
|
||||
later := start.Add(time.Minute)
|
||||
after := ratelimit.New(ratelimit.Limits{PerHour: limit})
|
||||
after.Load(before.Snapshot(), later)
|
||||
wantCount(t, after, client, later, hour)
|
||||
}
|
||||
|
||||
func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
start := midnight()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
limiter.Count(client, start)
|
||||
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||
|
||||
loaded := func(now time.Time) ratelimit.Client {
|
||||
t.Helper()
|
||||
|
||||
after := ratelimit.New(ratelimit.Limits{})
|
||||
after.Load(limiter.Snapshot(), now)
|
||||
|
||||
return after.Snapshot()[0]
|
||||
}
|
||||
|
||||
// Two minutes on, the window that ends then covers neither of the
|
||||
// minute's buckets, which are emptied; the hour's and the day's stay,
|
||||
// and so does the history.
|
||||
got := loaded(start.Add(2 * time.Minute))
|
||||
if got.Minute != (ratelimit.Buckets{}) || got.Hour.Current != 1 ||
|
||||
got.Day.Current != 1 || got.History.Requests != 1 {
|
||||
t.Errorf("loaded two minutes on as %+v", got)
|
||||
}
|
||||
|
||||
// A moment before, the window still covers some of the earlier one.
|
||||
got = loaded(start.Add(2*time.Minute - time.Nanosecond))
|
||||
if got.Minute.Current != 1 {
|
||||
t.Errorf("loaded just under two minutes on with minute buckets %+v",
|
||||
got.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDropsTheLeastRecentlySeenFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxClients = 20000
|
||||
|
||||
// clients.json lists the clients by address. Here each was last seen
|
||||
// a second before the one listed before it, so the last listed is the
|
||||
// one seen longest ago, and the one dropped.
|
||||
clients := make([]ratelimit.Client, maxClients+1)
|
||||
addr := netip.MustParseAddr("10.0.0.0")
|
||||
|
||||
for i := range clients {
|
||||
clients[i].Client = netip.PrefixFrom(addr, addr.BitLen())
|
||||
clients[i].History.LastSeen = midnight().Add(-time.Duration(i) * time.Second)
|
||||
addr = addr.Next()
|
||||
}
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{})
|
||||
limiter.Load(clients, midnight())
|
||||
|
||||
got := limiter.Snapshot()
|
||||
if len(got) != maxClients || got[0].Client != clients[0].Client ||
|
||||
got[maxClients-1].Client != clients[maxClients-1].Client {
|
||||
t.Errorf("%d clients kept, from %s to %s; want %d, from %s to %s",
|
||||
len(got), got[0].Client, got[len(got)-1].Client, maxClients,
|
||||
clients[0].Client, clients[maxClients-1].Client)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// The action a request line names: what smallwebwaf did with the
|
||||
@@ -24,8 +26,10 @@ const (
|
||||
// for, or whose answer could not be passed on.
|
||||
ActionUpstreamError = "upstream_error"
|
||||
// ActionRateLimited is a request refused because it took its client
|
||||
// over a rate limit, or came while the client was over one.
|
||||
// over a rate limit, which bans the client.
|
||||
ActionRateLimited = "rate_limited"
|
||||
// ActionBanned is a request refused because a ban covers its client.
|
||||
ActionBanned = "banned"
|
||||
// ActionDenied is a request refused because its client is in
|
||||
// SWWAF_DENY_NETS.
|
||||
ActionDenied = "denied"
|
||||
@@ -36,39 +40,98 @@ const (
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
// OffenceLimit is the offence a request line names for a request that
|
||||
// broke a rate limit.
|
||||
const OffenceLimit = "limit"
|
||||
|
||||
// timeLayout is RFC 3339 with milliseconds.
|
||||
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
// Line is one request's line in the request log. The field names are
|
||||
// those of the "Request log" section of SPEC.md.
|
||||
// Line is one request's line in the request log. The field names, and
|
||||
// their order, are those of the "Request log" section of SPEC.md. A field
|
||||
// that may not apply to a request is left out of its line when it does
|
||||
// not.
|
||||
//
|
||||
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
Time string `json:"time"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
PeerIP string `json:"peer_ip"`
|
||||
Country string `json:"country"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Protocol string `json:"protocol"`
|
||||
Status int `json:"status"`
|
||||
UpstreamStatus int `json:"upstream_status,omitempty"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Action string `json:"action"`
|
||||
Type string `json:"type"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached
|
||||
// smallwebwaf, or the trusted proxy in front of it.
|
||||
Time string `json:"time"`
|
||||
Instance string `json:"instance"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Method string `json:"method"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Protocol string `json:"protocol"`
|
||||
Status int `json:"status"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Request detail. RequestID is the X-Request-ID a trusted proxy sent,
|
||||
// or a new one, and is sent on to the app. ForwardedFor is the
|
||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||
// client is counted as.
|
||||
RequestID string `json:"request_id"`
|
||||
PeerIP string `json:"peer_ip"`
|
||||
ForwardedFor string `json:"forwarded_for,omitempty"`
|
||||
ClientGroup string `json:"client_group"`
|
||||
Country string `json:"country"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
// ContentLength is the length of its body the request announced.
|
||||
ContentLength int64 `json:"content_length,omitempty"`
|
||||
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
||||
// the request carried, by name in lower case.
|
||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||
HasCookie bool `json:"has_cookie,omitempty"`
|
||||
// Websocket is true when the connection was upgraded, as for a
|
||||
// WebSocket.
|
||||
Websocket bool `json:"websocket,omitempty"`
|
||||
|
||||
// Response detail, from the headers of the answer: the app's, as
|
||||
// passed on, or those of smallwebwaf's own. Aborted is true when the
|
||||
// client went away early.
|
||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||
UpstreamStatus int `json:"upstream_status,omitempty"`
|
||||
CacheControl string `json:"cache_control,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
|
||||
// The decision.
|
||||
Action string `json:"action"`
|
||||
// WouldAction is, in observe mode, the action enforce mode would have
|
||||
// taken with a request it would have refused: ActionDenied,
|
||||
// ActionBanned, ActionCountryDenied or ActionRateLimited.
|
||||
WouldAction string `json:"would_action,omitempty"`
|
||||
// Counts are the client's requests as the rate limits counted them
|
||||
// with this one, for a request they counted.
|
||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||
// LimitHit is the window whose rate limit the request went over:
|
||||
// minute, hour or day.
|
||||
LimitHit string `json:"limit_hit,omitempty"`
|
||||
// Aborted is true when the client went away early.
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
// DurationTotal and DurationUpstreamTotal are in milliseconds.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationUpstreamTotal float64 `json:"duration_upstream_total,omitempty"`
|
||||
// Offence is the offence the request was held as, OffenceLimit.
|
||||
Offence string `json:"offence,omitempty"`
|
||||
// BanExpires is when the ban the request made, or was refused under,
|
||||
// ends: a time, or "permanent".
|
||||
BanExpires string `json:"ban_expires,omitempty"`
|
||||
|
||||
// The timings, in milliseconds. DurationChecks is the time until the
|
||||
// checks were done. DurationUpstreamConnect, DurationUpstreamFirstByte
|
||||
// and DurationUpstreamTotal run from when the request was handed to the
|
||||
// app: until there was a connection to it, until the first byte of its
|
||||
// answer arrived, and until the end. Each but DurationTotal is nil for
|
||||
// a request that did not get that far.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
|
||||
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
|
||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||
}
|
||||
|
||||
// Write writes line to w as one JSON line marked "type":"request".
|
||||
|
||||
@@ -50,7 +50,12 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
unset := []string{
|
||||
"upstream_status", "limit_hit", "aborted", "duration_upstream_total",
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"upstream_status", "cache_control", "location", "aborted", "counts",
|
||||
"limit_hit", "offence", "ban_expires", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total",
|
||||
}
|
||||
for _, name := range unset {
|
||||
_, present := fields[name]
|
||||
|
||||
@@ -24,12 +24,14 @@ func TestHealthCheck(t *testing.T) {
|
||||
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
settings := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
stateDir: t.TempDir(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
}, out)
|
||||
exited <- run(ctx, settings, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||
// serves requests until it is told to stop, and then stops in an orderly
|
||||
// way.
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings
|
||||
// and the state files, serves requests until it is told to stop, and then
|
||||
// stops in an orderly way, writing the state files.
|
||||
package smallwebwaf
|
||||
|
||||
import (
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
// shutdownTimeout is how long requests in progress may take to finish
|
||||
@@ -55,8 +56,9 @@ func Main(version string) int {
|
||||
})
|
||||
}
|
||||
|
||||
// Run reads the settings, then serves requests until ctx is done. It
|
||||
// returns the process's exit status, 1 when smallwebwaf cannot start.
|
||||
// Run reads the settings and the state files, then serves requests until
|
||||
// ctx is done. It returns the process's exit status, 1 when smallwebwaf
|
||||
// cannot start.
|
||||
func Run(ctx context.Context, params Params) int {
|
||||
processLog := requestlog.NewProcessLogger(params.Stdout)
|
||||
|
||||
@@ -67,6 +69,34 @@ func Run(ctx context.Context, params Params) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// The state files give times in UTC.
|
||||
now := func() time.Time { return time.Now().UTC() }
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: params.Stdout,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: lookup.URL,
|
||||
Now: now,
|
||||
})
|
||||
|
||||
files, err := state.Load(state.Params{
|
||||
Dir: cfg.StateDir,
|
||||
WriteDelay: cfg.StateWriteDelay,
|
||||
CounterInterval: cfg.StateCounterInterval,
|
||||
Ledger: server.Ledger,
|
||||
Limiter: server.Limiter,
|
||||
GeoJS: server.GeoJS,
|
||||
Now: now,
|
||||
ProcessLog: processLog,
|
||||
Metrics: server.Metrics,
|
||||
})
|
||||
if err != nil {
|
||||
processLog.Error("cannot use the state files", "error", err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||
if err != nil {
|
||||
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR",
|
||||
@@ -75,26 +105,21 @@ func Run(ctx context.Context, params Params) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: params.Stdout,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: lookup.URL,
|
||||
})
|
||||
|
||||
processLog.Info("starting",
|
||||
"version", params.Version,
|
||||
"address", listener.Addr().String(),
|
||||
"settings", cfg)
|
||||
|
||||
return serve(ctx, server, listener, processLog)
|
||||
return serve(ctx, server.Server, listener, files, processLog)
|
||||
}
|
||||
|
||||
// serve serves requests on listener until ctx is done, then gives the
|
||||
// requests in progress shutdownTimeout to finish.
|
||||
// serve serves requests on listener, writes the state files as they are
|
||||
// due, and takes in an admin's edits of them, until ctx is done. Then it
|
||||
// gives the requests in progress shutdownTimeout to finish, and writes
|
||||
// every state file.
|
||||
func serve(
|
||||
ctx context.Context, server *http.Server, listener net.Listener,
|
||||
processLog *slog.Logger,
|
||||
files *state.Files, processLog *slog.Logger,
|
||||
) int {
|
||||
served := make(chan error, 1)
|
||||
|
||||
@@ -102,6 +127,22 @@ func serve(
|
||||
served <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
writing, stopWriting := context.WithCancel(ctx)
|
||||
defer stopWriting()
|
||||
|
||||
written := make(chan struct{})
|
||||
watched := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
files.Run(writing)
|
||||
close(written)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
files.Watch(writing)
|
||||
close(watched)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-served:
|
||||
processLog.Error("serving failed", "error", err.Error())
|
||||
@@ -131,6 +172,23 @@ func serve(
|
||||
return 1
|
||||
}
|
||||
|
||||
// Run and Watch have ended, so nothing else reads or writes the
|
||||
// files. Every request has ended too, but for two kinds
|
||||
// that Go's server does not wait for: one cut off because Shutdown
|
||||
// timed out, and one whose connection switched protocols, such as a
|
||||
// WebSocket. Such a request adds to its client's history only as it
|
||||
// ends, which can be after this write, and then that request is
|
||||
// missing from clients.json.
|
||||
<-written
|
||||
<-watched
|
||||
|
||||
err = files.WriteAll()
|
||||
if err != nil {
|
||||
processLog.Error("writing the state files failed", "error", err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
processLog.Info("stopped")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -24,9 +26,16 @@ const (
|
||||
// testVersion is the version the tests give smallwebwaf.
|
||||
testVersion = "test"
|
||||
// localhost is where the tests listen.
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
// greeting is what the tests' app answers.
|
||||
greeting = "hello from the app"
|
||||
)
|
||||
|
||||
// output collects what smallwebwaf writes on stdout.
|
||||
@@ -69,11 +78,19 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.buf.String())
|
||||
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// text returns everything written so far.
|
||||
func (o *output) text() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
return o.buf.String()
|
||||
}
|
||||
|
||||
// run runs smallwebwaf with the settings in env until ctx is done, and
|
||||
// returns its exit status.
|
||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||
@@ -107,6 +124,28 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortMetricsTokenStopsTheStartUnshown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "a-token-of-31-characters-at-all" //nolint:gosec // too short to use
|
||||
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{"SWWAF_METRICS_TOKEN": token}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
|
||||
line := out.line(t, "msg", "invalid setting")
|
||||
if line["error"] != "SWWAF_METRICS_TOKEN: is shorter than 32 characters" {
|
||||
t.Errorf("start refused with %v", line)
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), token) {
|
||||
t.Errorf("the output shows the token:\n%s", out.text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -121,7 +160,10 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{listenAddr: taken.Addr().String()}, out)
|
||||
status := run(t.Context(), map[string]string{
|
||||
listenAddr: taken.Addr().String(),
|
||||
stateDir: t.TempDir(),
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
@@ -132,11 +174,8 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
func TestServesUntilToldToStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "hello from the app")
|
||||
}))
|
||||
defer app.Close()
|
||||
appURL := startApp(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
out := &output{}
|
||||
@@ -145,12 +184,13 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
go func() {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
upstreamURL: appURL,
|
||||
stateDir: dir,
|
||||
}, out)
|
||||
}()
|
||||
|
||||
starting := out.line(t, "msg", "starting")
|
||||
wantStartingLine(t, starting, app.URL)
|
||||
wantStartingLine(t, starting, appURL, dir)
|
||||
|
||||
addr, _ := starting["address"].(string)
|
||||
wantGreeting(t, "http://"+addr+"/")
|
||||
@@ -170,30 +210,240 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
out.line(t, "msg", "stopped")
|
||||
}
|
||||
|
||||
func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rateLimitPerDay: "2",
|
||||
// Neither comes due in the test: the files are written as
|
||||
// smallwebwaf stops.
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
// The two requests a day allows, and a stop.
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantGreeting(t, url)
|
||||
wantGreeting(t, url)
|
||||
})
|
||||
|
||||
// After a restart the client has no fresh allowance: its third
|
||||
// request breaks the day limit, and bans it.
|
||||
out := runUntilStopped(t, env, func(url string) {
|
||||
wantRefused(t, url)
|
||||
})
|
||||
out.line(t, "action", "rate_limited")
|
||||
|
||||
// After another, the ban still refuses it.
|
||||
out = runUntilStopped(t, env, func(url string) {
|
||||
wantRefused(t, url)
|
||||
})
|
||||
out.line(t, "action", "banned")
|
||||
}
|
||||
|
||||
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scope = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
rateLimitPerDay: "1",
|
||||
scope: "24",
|
||||
}
|
||||
|
||||
// 203.0.113.9's second request breaks the day limit, and bans
|
||||
// 203.0.113.0/24.
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantStatus(t, url, "203.0.113.9", http.StatusOK)
|
||||
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
||||
})
|
||||
|
||||
// With each address a netblock of its own after a restart, that ban
|
||||
// still refuses all of 203.0.113.0/24. 198.51.100.7 is banned alone.
|
||||
env[scope] = "32"
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantStatus(t, url, "203.0.113.200", http.StatusForbidden)
|
||||
wantStatus(t, url, "203.0.114.1", http.StatusOK)
|
||||
wantStatus(t, url, "198.51.100.7", http.StatusOK)
|
||||
wantStatus(t, url, "198.51.100.7", http.StatusForbidden)
|
||||
})
|
||||
|
||||
// With /24 netblocks again, that ban still refuses 198.51.100.7, and
|
||||
// no other address.
|
||||
env[scope] = "24"
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantStatus(t, url, "198.51.100.7", http.StatusForbidden)
|
||||
wantStatus(t, url, "198.51.100.8", http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
// bans.json as an admin writes it with a ban, permanent, on
|
||||
// 203.0.113.0/24, and with none.
|
||||
oneBan = `{"version": 1, "bans": [{"netblock": "203.0.113.0/24", ` +
|
||||
`"start": "2026-10-06T00:00:00Z", "expires": null}]}`
|
||||
noBan = `{"version": 1, "bans": []}`
|
||||
)
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
trustedProxies: localhost + "/32",
|
||||
// No write comes due in the test, so only the watch on the
|
||||
// directory can take the edits in.
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
path := filepath.Join(dir, "bans.json")
|
||||
|
||||
saveUntilAnswered(t, path, oneBan, url, "203.0.113.9", http.StatusForbidden)
|
||||
wantStatus(t, url, "198.51.100.7", http.StatusOK)
|
||||
saveUntilAnswered(t, path, noBan, url, "203.0.113.9", http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "bans.json"), []byte("{\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write bans.json: %v", err)
|
||||
}
|
||||
|
||||
// The file ends at the newline that is the second byte of its first
|
||||
// line.
|
||||
wantStartRefused(t, dir, filepath.Join(dir, "bans.json")+", line 1, column 2: ")
|
||||
}
|
||||
|
||||
func TestUnwritableStateDirStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
||||
"SWWAF_STATE_DIR cannot be written: ")
|
||||
}
|
||||
|
||||
// wantStartRefused runs smallwebwaf with its state files in dir, and
|
||||
// checks that it stops at start, with an error that starts with want. If
|
||||
// it starts instead, it is stopped after waitLimit.
|
||||
func wantStartRefused(t *testing.T, dir, want string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, stop := context.WithTimeout(t.Context(), waitLimit)
|
||||
defer stop()
|
||||
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{listenAddr: localhost + ":0", stateDir: dir}, out)
|
||||
if status != 1 {
|
||||
t.Fatalf("exit status %d, want 1", status)
|
||||
}
|
||||
|
||||
line := out.line(t, "msg", "cannot use the state files")
|
||||
message, _ := line["error"].(string)
|
||||
|
||||
if !strings.HasPrefix(message, want) {
|
||||
t.Errorf("start refused with %q, want an error starting %q", message, want)
|
||||
}
|
||||
}
|
||||
|
||||
// startApp starts an app that answers every request with greeting, and
|
||||
// returns its URL.
|
||||
func startApp(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
app := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
}))
|
||||
t.Cleanup(app.Close)
|
||||
|
||||
return app.URL
|
||||
}
|
||||
|
||||
// runUntilStopped runs smallwebwaf with the settings in env, has use send
|
||||
// it requests at url, then stops it as SIGTERM does, checks that it
|
||||
// stopped in order, and returns its output.
|
||||
func runUntilStopped(
|
||||
t *testing.T, env map[string]string, use func(url string),
|
||||
) *output {
|
||||
t.Helper()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
use("http://" + addr + "/")
|
||||
stop()
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
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")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// wantStartingLine checks that the line at start gives the version and
|
||||
// every setting's value.
|
||||
func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
|
||||
func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
t.Helper()
|
||||
|
||||
settings, _ := line["settings"].(map[string]any)
|
||||
want := map[string]any{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: appURL,
|
||||
"SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_REQUEST_MAX_BYTES": "100M",
|
||||
"SWWAF_RESPONSE_MAX_BYTES": "5G",
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_NETS": "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
|
||||
"SWWAF_RATE_LIMIT_PER_DAY": "50000",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: appURL,
|
||||
stateDir: dir,
|
||||
"SWWAF_MODE": "enforce",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES": "32K",
|
||||
"SWWAF_CLIENT_IDLE_TIMEOUT": "120s",
|
||||
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
|
||||
"SWWAF_REQUEST_MAX_BYTES": "100M",
|
||||
"SWWAF_RESPONSE_MAX_BYTES": "5G",
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_NETS": "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
|
||||
rateLimitPerDay: "50000",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
"SWWAF_BAN_RESPONSE": "403",
|
||||
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
||||
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||
"SWWAF_MAX_BAN_DURATION": "7d",
|
||||
"SWWAF_MAX_BANS": "5000",
|
||||
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||
}
|
||||
|
||||
for name, value := range want {
|
||||
@@ -228,7 +478,93 @@ func wantGreeting(t *testing.T, url string) {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil || string(body) != "hello from the app" {
|
||||
if err != nil || string(body) != greeting {
|
||||
t.Errorf("got %q (%v), want the app's answer", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
// wantRefused checks that a request to url is refused with 403, the
|
||||
// default SWWAF_BAN_RESPONSE.
|
||||
func wantRefused(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// wantStatus checks that a request to url from the client at from, as
|
||||
// X-Forwarded-For names it, is answered with status.
|
||||
func wantStatus(t *testing.T, url, from string, status int) {
|
||||
t.Helper()
|
||||
|
||||
got := statusFrom(t, url, from)
|
||||
if got != status {
|
||||
t.Errorf("request from %s: status %d, want %d", from, got, status)
|
||||
}
|
||||
}
|
||||
|
||||
// saveUntilAnswered writes content to the state file at path, as an
|
||||
// admin saves an edit of it, until a request to url from the client at
|
||||
// from is answered with status. The file is written again before each
|
||||
// request, since smallwebwaf may not watch its directory yet when it is
|
||||
// first written. It waits as long as that takes, so that a slow test
|
||||
// process cannot fail the test.
|
||||
func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) {
|
||||
t.Helper()
|
||||
|
||||
for {
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
|
||||
if statusFrom(t, url, from) == status {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// statusFrom returns the status a request to url from the client at
|
||||
// from, as X-Forwarded-For names it, is answered with.
|
||||
func statusFrom(t *testing.T, url, from string) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Forwarded-For", from)
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
return res.StatusCode
|
||||
}
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
// Package state keeps smallwebwaf's state in JSON files in
|
||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md describes:
|
||||
// bans.json holds the bans, clients.json each client's counters and
|
||||
// history, and lookups.json GeoJS's answers. Load reads them at start,
|
||||
// Watch takes in an admin's edit of one while smallwebwaf runs, and Run
|
||||
// and WriteAll write them. The disk is read and written outside the
|
||||
// parts' locks, which are held only to take a snapshot or to put in what
|
||||
// a file holds, so that no request waits on the disk.
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// version is the version of the files' format, the only one read.
|
||||
const version = 1
|
||||
|
||||
// fileMode lets the smallwebwaf user alone read and write the files, which
|
||||
// hold visitors' addresses.
|
||||
const fileMode = 0o600
|
||||
|
||||
// The state files' names.
|
||||
const (
|
||||
bansJSON = "bans.json"
|
||||
clientsJSON = "clients.json"
|
||||
lookupsJSON = "lookups.json"
|
||||
)
|
||||
|
||||
var (
|
||||
errVersion = errors.New("unknown version")
|
||||
// errMissing is for an entry without a field it needs.
|
||||
errMissing = errors.New("has no")
|
||||
)
|
||||
|
||||
// Params are what Load needs.
|
||||
type Params struct {
|
||||
// Dir is the directory of the state files (SWWAF_STATE_DIR).
|
||||
Dir string
|
||||
// WriteDelay is how long after a ban is made bans.json is written
|
||||
// (SWWAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
||||
// is (SWWAF_STATE_COUNTER_INTERVAL).
|
||||
WriteDelay time.Duration
|
||||
CounterInterval time.Duration
|
||||
// Ledger, Limiter and GeoJS hold the state.
|
||||
Ledger *bans.Ledger
|
||||
Limiter *ratelimit.Limiter
|
||||
GeoJS *lookup.GeoJS
|
||||
// Now tells the time by which the counters' buckets run out, normally
|
||||
// time.Now in UTC.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives what was read and taken in, the edits set aside,
|
||||
// and the writes that fail.
|
||||
ProcessLog *slog.Logger
|
||||
// Metrics count each file's writes, and the edits taken in and set
|
||||
// aside.
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// Files are the state files of a running smallwebwaf.
|
||||
type Files struct {
|
||||
params Params
|
||||
|
||||
// mu is held while a file is read for an edit, and while it is
|
||||
// written, so that Watch and the writes take turns. No request takes
|
||||
// it.
|
||||
mu sync.Mutex
|
||||
// sums are the SHA-256 sums of what each file held, by name, when
|
||||
// smallwebwaf last read or wrote it. A file that holds anything else
|
||||
// has been edited since.
|
||||
sums map[string][sha256.Size]byte
|
||||
}
|
||||
|
||||
// bansFile is bans.json, indented for an admin to read and edit.
|
||||
type bansFile struct {
|
||||
Version int `json:"version"`
|
||||
Bans []banEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
|
||||
// null.
|
||||
type banEntry struct {
|
||||
Netblock netip.Prefix `json:"netblock"`
|
||||
Start time.Time `json:"start"`
|
||||
Expires *time.Time `json:"expires"`
|
||||
Notes bans.Notes `json:"notes"`
|
||||
}
|
||||
|
||||
// clientsFile is clients.json, with each client on a line of its own.
|
||||
type clientsFile struct {
|
||||
Version int `json:"version"`
|
||||
Clients []ratelimit.Client `json:"clients"`
|
||||
}
|
||||
|
||||
// lookupsFile is lookups.json, with each answer on a line of its own.
|
||||
type lookupsFile struct {
|
||||
Version int `json:"version"`
|
||||
Lookups []lookup.Answer `json:"lookups"`
|
||||
}
|
||||
|
||||
// stateFile is the struct of a state file. Once the file is decoded, its
|
||||
// check refuses the first entry without a field it needs, which would
|
||||
// otherwise be read as something the entry does not say. data is the
|
||||
// file, for a field that may be null or "" but not left out, which the
|
||||
// struct cannot tell apart.
|
||||
type stateFile interface {
|
||||
check(data []byte) error
|
||||
}
|
||||
|
||||
// Load checks that files can be written in Dir, and reads the state files
|
||||
// in it into the ledger, the limiter and GeoJS. A missing file is empty
|
||||
// state, as on a first start. A file that does not parse, has an unknown
|
||||
// version, or has an entry without a field it needs, is an error that
|
||||
// names the file and, where the JSON decoder tells it, the line and
|
||||
// column, or else the entry.
|
||||
func Load(params Params) (*Files, error) {
|
||||
err := checkWritable(params.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err)
|
||||
}
|
||||
|
||||
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||
|
||||
bansRead, bansErr := f.read(bansJSON)
|
||||
clientsRead, clientsErr := f.read(clientsJSON)
|
||||
lookupsRead, lookupsErr := f.read(lookupsJSON)
|
||||
|
||||
err = errors.Join(bansErr, clientsErr, lookupsErr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params.ProcessLog.Info("read the state files", "directory", params.Dir,
|
||||
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
||||
// made in between, and every file every CounterInterval, until ctx is
|
||||
// done. A write that fails is logged, and the file is written again at
|
||||
// its next write. Each write takes in an admin's edit of its file first,
|
||||
// as writeFile describes.
|
||||
func (f *Files) Run(ctx context.Context) {
|
||||
interval := time.NewTicker(f.params.CounterInterval)
|
||||
defer interval.Stop()
|
||||
|
||||
var bansDue <-chan time.Time // nil while no ban waits to be written
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-f.params.Ledger.Changed():
|
||||
if bansDue == nil {
|
||||
bansDue = time.After(f.params.WriteDelay)
|
||||
}
|
||||
case <-bansDue:
|
||||
bansDue = nil
|
||||
|
||||
f.logFailure(f.writeFile(bansJSON))
|
||||
case <-interval.C:
|
||||
f.logFailure(f.WriteAll())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WriteAll writes every state file, as smallwebwaf stops. A file that
|
||||
// fails does not keep the others from being written.
|
||||
func (f *Files) WriteAll() error {
|
||||
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||
f.writeFile(lookupsJSON))
|
||||
}
|
||||
|
||||
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||
// state file as soon as it is saved: what the file holds replaces what
|
||||
// smallwebwaf held for it. An edit that does not parse is left for the
|
||||
// file's next write, which sets it aside, since a file can be read while
|
||||
// an editor is still writing it. If Dir cannot be watched, that is
|
||||
// logged, and an edit is taken in only before its file is written.
|
||||
func (f *Files) Watch(ctx context.Context) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
_ = watcher.Close()
|
||||
}()
|
||||
|
||||
err = watcher.Add(f.params.Dir)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("cannot watch the state files for edits",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Info("watching the state files for edits",
|
||||
"directory", f.params.Dir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event := <-watcher.Events:
|
||||
switch name := filepath.Base(event.Name); name {
|
||||
case bansJSON, clientsJSON, lookupsJSON:
|
||||
f.fileChanged(name)
|
||||
}
|
||||
case err = <-watcher.Errors:
|
||||
f.params.ProcessLog.Warn("watching the state files failed",
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logFailure logs a write that failed.
|
||||
func (f *Files) logFailure(err error) {
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error("writing the state files failed",
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// fileChanged takes in what the state file name holds, as Watch sees it
|
||||
// change, if that is an edit made since smallwebwaf last read or wrote
|
||||
// the file. A file that cannot be read or does not parse is left for its
|
||||
// next write.
|
||||
func (f *Files) fileChanged(name string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err != nil || !changed {
|
||||
return
|
||||
}
|
||||
|
||||
_ = f.takeInEdit(name, data)
|
||||
}
|
||||
|
||||
// takeInEdit takes in data, an edit of the state file name, as takeIn
|
||||
// does, and counts and logs it. Every edit taken in while smallwebwaf
|
||||
// runs, by Watch or by a write, is taken in here. An edit that does not
|
||||
// parse is neither counted nor logged, and takeIn's error returned.
|
||||
func (f *Files) takeInEdit(name string, data []byte) error {
|
||||
_, err := f.takeIn(name, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Counted before it is logged, so that the count is there once the
|
||||
// log line is.
|
||||
f.params.Metrics.StateFileEditTakenIn(name)
|
||||
f.params.ProcessLog.Info("took in an edit of a state file",
|
||||
"file", filepath.Join(f.params.Dir, name))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// read takes in the state file name at start, and returns how many
|
||||
// entries it holds. A missing file holds none.
|
||||
func (f *Files) read(name string) (int, error) {
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err != nil || !changed {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return f.takeIn(name, data)
|
||||
}
|
||||
|
||||
// readChanged returns what the state file name holds, and whether that
|
||||
// has changed since smallwebwaf last read or wrote the file, as it has
|
||||
// for a file smallwebwaf never read or wrote. A missing file has not
|
||||
// changed: it is written again at its next write.
|
||||
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return data, sha256.Sum256(data) != f.sums[name], nil
|
||||
}
|
||||
|
||||
// takeIn parses data, what the state file name holds, puts it into the
|
||||
// part that keeps that state, in place of what the part held, and returns
|
||||
// how many entries the file holds. An error names the file and, where the
|
||||
// JSON decoder tells it, the line and column, or else the entry.
|
||||
func (f *Files) takeIn(name string, data []byte) (int, error) {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
var entries int
|
||||
|
||||
switch name {
|
||||
case bansJSON:
|
||||
var file bansFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
held := make([]bans.Ban, 0, len(file.Bans))
|
||||
for _, entry := range file.Bans {
|
||||
held = append(held, entry.ban())
|
||||
}
|
||||
|
||||
f.params.Ledger.Load(held)
|
||||
entries = len(held)
|
||||
case clientsJSON:
|
||||
var file clientsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f.params.Limiter.Load(file.Clients, f.params.Now())
|
||||
entries = len(file.Clients)
|
||||
case lookupsJSON:
|
||||
var file lookupsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f.params.GeoJS.Load(file.Lookups)
|
||||
entries = len(file.Lookups)
|
||||
}
|
||||
|
||||
f.sums[name] = sha256.Sum256(data)
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// writeFile writes the state file name from what smallwebwaf holds. An
|
||||
// edit made since smallwebwaf last read or wrote the file is taken in
|
||||
// first, so that it is not overwritten, or set aside if it does not
|
||||
// parse. A file that cannot be read, or an edit that cannot be set
|
||||
// aside, is left as it is, and the write given up. Every write is counted
|
||||
// in the metrics, and one that fails or is given up as a failure.
|
||||
func (f *Files) writeFile(name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
data, changed, err := f.readChanged(name)
|
||||
if err == nil && changed {
|
||||
err = f.takeInEdit(name, data)
|
||||
if err != nil {
|
||||
err = f.setAside(name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
data, err = f.encode(name)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("encode %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = write(f.params.Dir, name, data)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// The file holds data from here on, even if the directory sync
|
||||
// fails, so that its next read does not take it for an admin's
|
||||
// edit.
|
||||
f.sums[name] = sha256.Sum256(data)
|
||||
err = syncDirectory(f.params.Dir)
|
||||
}
|
||||
|
||||
f.params.Metrics.StateFileWritten(name, len(data), err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
// the file the error is. If the rename fails, the edit is left as it is,
|
||||
// and the error returned is parseErr joined with the rename's.
|
||||
func (f *Files) setAside(name string, parseErr error) error {
|
||||
path := filepath.Join(f.params.Dir, name)
|
||||
|
||||
err := os.Rename(path, path+".bad")
|
||||
if err != nil {
|
||||
return errors.Join(parseErr, err)
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Error("set aside an edit of a state file that does not parse",
|
||||
"file", path+".bad", "error", parseErr.Error())
|
||||
f.params.Metrics.StateFileEditSetAside(name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encode returns the state file name as smallwebwaf writes it, from a
|
||||
// snapshot of the part that keeps that state.
|
||||
func (f *Files) encode(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case bansJSON:
|
||||
held := f.params.Ledger.Snapshot()
|
||||
|
||||
file := bansFile{Version: version, Bans: make([]banEntry, 0, len(held))}
|
||||
for _, ban := range held {
|
||||
file.Bans = append(file.Bans, newBanEntry(ban))
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(data, '\n'), nil
|
||||
case clientsJSON:
|
||||
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
|
||||
default: // lookups.json
|
||||
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// newBanEntry returns ban as bans.json holds it.
|
||||
func newBanEntry(ban bans.Ban) banEntry {
|
||||
entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes}
|
||||
if !ban.Permanent() {
|
||||
entry.Expires = &ban.Expires
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// ban returns the ban an entry of bans.json holds.
|
||||
func (e banEntry) ban() bans.Ban {
|
||||
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Notes: e.Notes}
|
||||
if e.Expires != nil {
|
||||
ban.Expires = *e.Expires
|
||||
}
|
||||
|
||||
return ban
|
||||
}
|
||||
|
||||
// check refuses a ban without a netblock, which would refuse every IPv6
|
||||
// client, a start, from which the length of the netblock's next ban is
|
||||
// worked out, or an expires, which would make it permanent. A permanent
|
||||
// ban's expires is null, which Bans cannot tell from a missing one, so
|
||||
// each expires is read again as written.
|
||||
func (f *bansFile) check(data []byte) error {
|
||||
var written struct {
|
||||
Bans []struct {
|
||||
Expires json.RawMessage `json:"expires"`
|
||||
} `json:"bans"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &written)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, entry := range f.Bans {
|
||||
switch {
|
||||
case !entry.Netblock.IsValid():
|
||||
return missing(i, "netblock")
|
||||
case entry.Start.IsZero():
|
||||
return missing(i, "start")
|
||||
case written.Bans[i].Expires == nil:
|
||||
return missing(i, "expires")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// check refuses a client without its address, which would count nobody's
|
||||
// requests, or with requests in a window but no start, which would drop
|
||||
// them and give the client a fresh allowance.
|
||||
func (f *clientsFile) check([]byte) error {
|
||||
for i, client := range f.Clients {
|
||||
switch {
|
||||
case !client.Client.IsValid():
|
||||
return missing(i, "client")
|
||||
case countsWithoutStart(client.Minute):
|
||||
return missing(i, "minute.start")
|
||||
case countsWithoutStart(client.Hour):
|
||||
return missing(i, "hour.start")
|
||||
case countsWithoutStart(client.Day):
|
||||
return missing(i, "day.start")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// check refuses an answer without a client, which would answer for
|
||||
// nobody, a country, which would place the client nowhere, or the time
|
||||
// GeoJS gave it, which would drop it. "" is the country of a client
|
||||
// GeoJS cannot place, which Lookups cannot tell from a missing one, so
|
||||
// each country is read again as written.
|
||||
func (f *lookupsFile) check(data []byte) error {
|
||||
var written struct {
|
||||
Lookups []struct {
|
||||
Country *string `json:"country"`
|
||||
} `json:"lookups"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &written)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, answer := range f.Lookups {
|
||||
switch {
|
||||
case !answer.Client.IsValid():
|
||||
return missing(i, "client")
|
||||
case written.Lookups[i].Country == nil:
|
||||
return missing(i, "country")
|
||||
case answer.Answered.IsZero():
|
||||
return missing(i, "answered")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countsWithoutStart reports whether b holds requests but no start, which
|
||||
// places them in time.
|
||||
func countsWithoutStart(b ratelimit.Buckets) bool {
|
||||
return b.Start.IsZero() && (b.Current != 0 || b.Previous != 0)
|
||||
}
|
||||
|
||||
// missing returns the error for entry i, counted from 0, of a state file,
|
||||
// which has no field.
|
||||
func missing(i int, field string) error {
|
||||
return fmt.Errorf("entry %d %w %q", i+1, errMissing, field)
|
||||
}
|
||||
|
||||
// encodeOnePerLine encodes a state file whose entries, under key, are one
|
||||
// to a line, so that grep shows everything about one client.
|
||||
func encodeOnePerLine[E any](key string, entries []E) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
fmt.Fprintf(&b, "{\n \"version\": %d,\n %q: [", version, key)
|
||||
|
||||
for i, entry := range entries {
|
||||
line, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
|
||||
b.WriteString("\n ")
|
||||
b.Write(line)
|
||||
}
|
||||
|
||||
b.WriteString("\n ]\n}\n")
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// checkWritable makes a file in dir and removes it again.
|
||||
func checkWritable(dir string) error {
|
||||
file, err := os.CreateTemp(dir, "write-check-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.Join(file.Close(), os.Remove(file.Name()))
|
||||
}
|
||||
|
||||
// parse reads data, what the state file at path holds, into file, a
|
||||
// pointer to that file's struct, and checks its entries.
|
||||
func parse(path string, data []byte, file stateFile) error {
|
||||
// The version is read first, so that a file of another version is
|
||||
// refused for that, and not for an entry this version cannot read.
|
||||
var header struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &header)
|
||||
if err == nil && header.Version != version {
|
||||
err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d",
|
||||
errVersion, header.Version, version)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
// A field this version does not know is most likely misspelt, and
|
||||
// its value would be lost without a word.
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(file)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = file.check(data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s%s: %w", path, position(data, err), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// position returns where in data err was found, as ", line L, column C"
|
||||
// of the last byte the JSON decoder read, or "" when err does not tell.
|
||||
func position(data []byte, err error) string {
|
||||
var (
|
||||
syntaxErr *json.SyntaxError
|
||||
typeErr *json.UnmarshalTypeError
|
||||
read int64
|
||||
)
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxErr):
|
||||
read = syntaxErr.Offset
|
||||
case errors.As(err, &typeErr):
|
||||
read = typeErr.Offset
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
before := data[:max(min(read, int64(len(data)))-1, 0)]
|
||||
line := bytes.Count(before, []byte("\n")) + 1
|
||||
column := len(before) - bytes.LastIndexByte(before, '\n')
|
||||
|
||||
return fmt.Sprintf(", line %d, column %d", line, column)
|
||||
}
|
||||
|
||||
// write writes data to the file name in dir so that a crash at any
|
||||
// moment leaves either the old file or the new one, whole: data goes to a
|
||||
// temporary file in the same directory, which is synced and renamed over
|
||||
// name. syncDirectory must follow, so that the rename lasts.
|
||||
func write(dir, name string, data []byte) error {
|
||||
path := filepath.Join(dir, name)
|
||||
temporary := path + ".tmp"
|
||||
|
||||
err := writeSynced(temporary, data)
|
||||
if err == nil {
|
||||
err = os.Rename(temporary, path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
||||
func syncDirectory(dir string) error {
|
||||
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.Join(directory.Sync(), directory.Close())
|
||||
}
|
||||
|
||||
// writeSynced writes data to the file at path, and syncs it to the disk.
|
||||
func writeSynced(path string, data []byte) error {
|
||||
//nolint:gosec // a state file's temporary file, in SWWAF_STATE_DIR
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = file.Write(data)
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
|
||||
return errors.Join(err, file.Close())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The test is on write itself: a state file is read before it is
|
||||
// written, and a directory in its place fails that read first.
|
||||
func TestFailedRenameLeavesNoTemporaryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
// A directory named bans.json cannot be renamed over.
|
||||
err := os.Mkdir(filepath.Join(dir, bansJSON), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = write(dir, bansJSON, []byte("{}\n"))
|
||||
if err == nil {
|
||||
t.Error("writing over a directory did not fail")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
|
||||
if len(entries) != 1 || entries[0].Name() != bansJSON {
|
||||
t.Errorf("%s holds %v, want only bans.json", dir, entries)
|
||||
}
|
||||
}
|
||||
+44
-11
@@ -1,12 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/example-app: build the image, and on it the example app in
|
||||
# deploy/example-app, then run the app's container and check that the
|
||||
# health check passes, that a request is served through smallwebwaf,
|
||||
# that `sv stop` stops smallwebwaf in order, and that `docker stop`
|
||||
# stops the container without having to kill it. The container and both
|
||||
# images are removed however the script ends. Building the app needs
|
||||
# network access, for nixpkgs' binary cache. script/check does not run
|
||||
# this.
|
||||
# deploy/example-app, then run the app's container with a volume for the
|
||||
# state files and check that the health check passes, that a request is
|
||||
# served through smallwebwaf, that a second one in a minute bans the
|
||||
# client, that `sv stop` stops smallwebwaf in order, that `docker stop`
|
||||
# stops the container without having to kill it, and that a new
|
||||
# container on the same volume still refuses the banned client. The
|
||||
# containers, the volume and both images are removed however the script
|
||||
# ends. Building the app needs network access, for nixpkgs' binary cache.
|
||||
# script/check does not run this.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -18,9 +20,11 @@ NAME="$("$SCRIPT_DIR/projectname")-example-$$"
|
||||
IMAGE="$NAME-base"
|
||||
APP_IMAGE="$NAME-app"
|
||||
CONTAINER="$NAME"
|
||||
VOLUME="$NAME-state"
|
||||
|
||||
cleanup() {
|
||||
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
|
||||
docker volume rm --force "$VOLUME" >/dev/null 2>&1 || true
|
||||
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -53,6 +57,26 @@ logged() {
|
||||
docker logs "$CONTAINER" 2>&1 | grep -qF "$1"
|
||||
}
|
||||
|
||||
# start_container: run the app's container, with the state files on the
|
||||
# volume and a rate limit of one request a minute, and wait until it is
|
||||
# healthy.
|
||||
start_container() {
|
||||
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
||||
--volume "$VOLUME:/var/lib/smallwebwaf" \
|
||||
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
|
||||
"$APP_IMAGE" >/dev/null
|
||||
wait_for "the health check did not pass" healthy
|
||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
||||
}
|
||||
|
||||
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
|
||||
# default.
|
||||
refused() {
|
||||
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--max-time 10 "http://$address/")" || true
|
||||
[ "$code" = 403 ]
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
trap cleanup EXIT
|
||||
@@ -62,18 +86,20 @@ main() {
|
||||
docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \
|
||||
-t "$APP_IMAGE" deploy/example-app
|
||||
|
||||
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
||||
"$APP_IMAGE" >/dev/null
|
||||
wait_for "the health check did not pass" healthy
|
||||
docker volume create "$VOLUME" >/dev/null
|
||||
start_container
|
||||
echo "example-app: the health check passes"
|
||||
|
||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
||||
page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
|
||||
fail "no answer on port 8080"
|
||||
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
|
||||
wait_for "smallwebwaf logged no request it forwarded" logged '"action":"forward"'
|
||||
echo "example-app: smallwebwaf passes a request to the app and its answer back"
|
||||
|
||||
refused || fail "a second request in a minute was not refused"
|
||||
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"'
|
||||
echo "example-app: a second request in a minute bans the client"
|
||||
|
||||
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
|
||||
fail "sv stop smallwebwaf failed"
|
||||
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
|
||||
@@ -83,6 +109,13 @@ main() {
|
||||
status="$(docker inspect --format '{{.State.ExitCode}}' "$CONTAINER")"
|
||||
[ "$status" = 0 ] || fail "docker stop left exit status $status"
|
||||
echo "example-app: docker stop stops the container in order"
|
||||
|
||||
docker rm "$CONTAINER" >/dev/null
|
||||
start_container
|
||||
refused || fail "the new container let the banned client through"
|
||||
wait_for "smallwebwaf logged no request refused under the ban" \
|
||||
logged '"action":"banned"'
|
||||
echo "example-app: a new container on the same volume keeps the ban"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/run: build bin/smallwebwaf with script/build and run it, with
|
||||
# the settings in the environment.
|
||||
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the
|
||||
# state files go in bin/state, beside the binary.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -8,6 +9,11 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/build"
|
||||
if [ -z "${SWWAF_STATE_DIR+set}" ]; then
|
||||
SWWAF_STATE_DIR="$ROOT/bin/state"
|
||||
export SWWAF_STATE_DIR
|
||||
mkdir -p "$SWWAF_STATE_DIR"
|
||||
fi
|
||||
exec "$ROOT/bin/smallwebwaf"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
set -euo pipefail
|
||||
|
||||
# runit's run script for smallwebwaf, run again whenever smallwebwaf
|
||||
# exits; the wait spaces out the restarts. exec, so that the signal
|
||||
# `sv stop` sends reaches smallwebwaf itself.
|
||||
# exits; the wait spaces out the restarts. The state directory and every
|
||||
# file in it are given to the smallwebwaf user, so that a volume mounted
|
||||
# there needs no change of owner; chown -R changes a symbolic link itself,
|
||||
# never what it points to. exec, so that the signal `sv stop` sends
|
||||
# reaches smallwebwaf itself.
|
||||
main() {
|
||||
sleep 1
|
||||
chown -R smallwebwaf:smallwebwaf "${SWWAF_STATE_DIR:-/var/lib/smallwebwaf}"
|
||||
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user