AS number and country looked up for every client #97
@@ -22,26 +22,28 @@ 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. So are the four parts of
|
and the idle time as settings, which come last in it. So are the four parts of
|
||||||
the stage after it: the rule files, with the bans for a clear sign of attack,
|
the stage after it: the rule files, with the bans for a clear sign of attack,
|
||||||
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
||||||
Slack and ntfy, and remote log sending. `smallwebwaf` passes each request to the
|
Slack and ntfy, and remote log sending. So is the first part of the stage after
|
||||||
app and the app's answer back, unchanged, within its timeouts and size limits,
|
that: the AS number and country of every client, looked up through GeoJS.
|
||||||
works out each client's address, bans a client that sends too many requests, not
|
`smallwebwaf` passes each request to the app and the app's answer back,
|
||||||
counting those for the paths you choose, refuses a client that comes from a
|
unchanged, within its timeouts and size limits, works out each client's address,
|
||||||
country you refuse or from a network you refuse, lets the networks you choose
|
looks up its AS number and country unless you switch that off, bans a client
|
||||||
through, checks each request against the rule files and bans a client whose
|
that sends too many requests, not counting those for the paths you choose,
|
||||||
request is a clear sign of attack, keeps its bans, each client's counters and
|
refuses a client that comes from a country you refuse or from a network you
|
||||||
history, and GeoJS's answers in JSON files across restarts, takes in your edits
|
refuse, lets the networks you choose through, checks each request against the
|
||||||
of those files, such as a ban you make, keep or lift, and of the rule files
|
rule files and bans a client whose request is a clear sign of attack, keeps its
|
||||||
while it runs, writes a JSON log line for every request, sends its log lines to
|
bans, each client's counters and history, and GeoJS's answers in JSON files
|
||||||
a syslog server too if you name one, sends an alert to a webhook, to Slack and
|
across restarts, takes in your edits of those files, such as a ban you make,
|
||||||
to ntfy, each if you name one, for each ban it makes or makes permanent, for
|
keep or lift, and of the rule files while it runs, writes a JSON log line for
|
||||||
GeoJS failing and for a rule file or state file with an error, serves Prometheus
|
every request, sends its log lines to a syslog server too if you name one, sends
|
||||||
metrics to a scraper that holds the metrics token, lets an admin who holds the
|
an alert to a webhook, to Slack and to ntfy, each if you name one, for each ban
|
||||||
admin token list, add and lift bans and ask what it knows of a client, and in
|
it makes or makes permanent, for GeoJS failing and for a rule file or state file
|
||||||
`observe` mode passes on the requests it would refuse, logging what it would
|
with an error, serves Prometheus metrics to a scraper that holds the metrics
|
||||||
have done with them. It comes as the image the app's own image is built on. The
|
token, lets an admin who holds the admin token list, add and lift bans and ask
|
||||||
rest of the design comes after that, in the order of the build order in
|
what it knows of a client, and in `observe` mode passes on the requests it would
|
||||||
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
|
refuse, logging what it would have done with them. It comes as the image the
|
||||||
[`EVALUATION.md`](EVALUATION.md).
|
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
|
## Getting started
|
||||||
|
|
||||||
@@ -115,15 +117,15 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
is not counted for the rate limits. A ban sets the client's counters back to
|
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
|
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
|
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
|
AS number, AS name and country once they are looked up, the netblock's
|
||||||
seen, how many of them the ban has refused, and how many bans the netblock had
|
requests since it was first seen, how many of them the ban has refused, and
|
||||||
before, for a broken limit, for a clear sign of attack and by an admin. At
|
how many bans the netblock had before, for a broken limit, for a clear sign of
|
||||||
most `SWWAF_MAX_BANS` bans `smallwebwaf` made are kept, past, active and
|
attack and by an admin. At most `SWWAF_MAX_BANS` bans `smallwebwaf` made are
|
||||||
permanent; past that, the earliest such ban of the netblock that has gone
|
kept, past, active and permanent; past that, the earliest such ban of the
|
||||||
longest without a request is dropped first. The bans whose cause is `admin`,
|
netblock that has gone longest without a request is dropped first. The bans
|
||||||
those you make or keep, are kept besides, and never dropped. `bans.json` shows
|
whose cause is `admin`, those you make or keep, are kept besides, and never
|
||||||
the bans and their notes, a restart lifts none, and you make, keep or lift a
|
dropped. `bans.json` shows the bans and their notes, a restart lifts none, and
|
||||||
ban by editing it (see "State files" below).
|
you make, keep or lift a ban by editing it (see "State files" below).
|
||||||
- Checks each request against the rules of the rule files (see "Rule files"
|
- Checks each request against the rules of the rule files (see "Rule files"
|
||||||
below) after the rate limits, and before its body is read. A `log` rule that
|
below) after the rate limits, and before its body is read. A `log` rule that
|
||||||
matches is noted in the log line; a `block` rule refuses the request with
|
matches is noted in the log line; a `block` rule refuses the request with
|
||||||
@@ -139,16 +141,23 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
back to zero, and does not make the netblock's next ban for a broken limit
|
back to zero, and does not make the netblock's next ban for a broken limit
|
||||||
longer. Its notes give the id and the target of the rule that matched in place
|
longer. Its notes give the id and the target of the rule that matched in place
|
||||||
of the limit.
|
of the limit.
|
||||||
|
- Looks up the AS number and country of every client through GeoJS, after the
|
||||||
|
static lists and bans, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Country and
|
||||||
|
AS number lookup" below), for the request log, the client's history, the notes
|
||||||
|
of its bans, their alerts and the metrics. A request waits for its client's
|
||||||
|
first answer only while a setting acts on it, a country list or
|
||||||
|
`SWWAF_ADD_LOOKUP_HEADERS`. Otherwise it goes on at once, and the answer
|
||||||
|
reaches the client's history and the notes of its bans when it comes, but not
|
||||||
|
the log lines of the requests that went on without it, nor the alerts already
|
||||||
|
raised for those bans.
|
||||||
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
- 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
|
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
|
is not counted for the rate limits. A client on a private, loopback or
|
||||||
set, each client's country is looked up through GeoJS (see "Country and AS
|
link-local address has no country and is never looked up:
|
||||||
number lookup" below); with neither set, no visitor's address leaves the host.
|
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is in
|
||||||
A client on a private, loopback or link-local address has no country and is
|
`SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
||||||
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
|
- Checks the client's own address against the static lists, the three netblock
|
||||||
settings below, before anything else, its country included. A client in
|
settings below, before anything else, its lookup included. A client in
|
||||||
`SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits and the rule
|
`SWWAF_ALLOW_NETS` skips bans, the country lists, the rate limits and the rule
|
||||||
files, and is not looked up; the timeouts and size limits still apply. A
|
files, 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
|
client in `SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its
|
||||||
@@ -267,6 +276,22 @@ effective settings are logged at start.
|
|||||||
percent-encoded, such as a space, is written percent-encoded in a prefix, as
|
percent-encoded, such as a space, is written percent-encoded in a prefix, as
|
||||||
in `/my%20files/`, and there are no wildcards: `*` is a character like any
|
in `/my%20files/`, and there are no wildcards: `*` is a character like any
|
||||||
other.
|
other.
|
||||||
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): where each client's AS number and
|
||||||
|
country are looked up: `geojs`, the GeoJS web service, which is then told the
|
||||||
|
address of every new visitor, or `off`, which looks up no client and sends no
|
||||||
|
address to GeoJS. `file`, for the IPinfo Lite database, comes with
|
||||||
|
https://git.eeqj.de/sneak/smallwebwaf/issues/22. With `off`, a country list
|
||||||
|
that is not empty, or `SWWAF_ADD_LOOKUP_HEADERS` set to `true`, stops the
|
||||||
|
start, with a message naming it and `SWWAF_LOOKUP_SOURCE`.
|
||||||
|
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
|
||||||
|
client's first answer while a setting acts on it, and how long a request to
|
||||||
|
GeoJS may take before it is abandoned.
|
||||||
|
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the
|
||||||
|
client's AS number, such as `AS64496`, in `X-Client-ASN`, and its country in
|
||||||
|
`X-Client-Country`, leaving out one that is unknown. A request then waits for
|
||||||
|
its client's first answer, as it does while a country list is set. Whatever
|
||||||
|
this setting says, any `X-Client-ASN` or `X-Client-Country` the client sent,
|
||||||
|
in any case, is removed, so that the app never receives a client's own.
|
||||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||||
for example `cn,ru,kp`.
|
for example `cn,ru,kp`.
|
||||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
@@ -318,8 +343,9 @@ effective settings are logged at start.
|
|||||||
shorter than 32 characters stops the start. The settings logged at start show
|
shorter than 32 characters stops the start. The settings logged at start show
|
||||||
`********` in its place. Given as a file, it can be kept out of the app's
|
`********` in its place. Given as a file, it can be kept out of the app's
|
||||||
reach (see "Settings given as files" below).
|
reach (see "Settings given as files" below).
|
||||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
||||||
own in the metrics by country; the others are counted as `other`.
|
countries get series of their own in the metrics by AS number and by country;
|
||||||
|
the others are counted as `other`.
|
||||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
||||||
rule files. A directory that does not exist stops the start.
|
rule files. A directory that does not exist stops the start.
|
||||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks
|
||||||
@@ -384,14 +410,13 @@ 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
|
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
|
`su`, stops the start, and so does a code on both country lists. `off` switches
|
||||||
a timeout, a size limit, a rate limit, `SWWAF_ALERT_COOLDOWN` or
|
a timeout, a size limit, a rate limit, `SWWAF_ALERT_COOLDOWN` or
|
||||||
`SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, the ban
|
`SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
||||||
settings, the state settings, `SWWAF_METRICS_TOP_N` and
|
`SWWAF_LOOKUP_TIMEOUT`, the ban settings, the state settings,
|
||||||
`SWWAF_LOG_REMOTE_BUFFER` cannot be off.
|
`SWWAF_METRICS_TOP_N` and `SWWAF_LOG_REMOTE_BUFFER` cannot be off.
|
||||||
|
|
||||||
Several limits are fixed rather than settings. At most 20,000 clients are kept,
|
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
|
with their counters and history, and an IPv6 client is counted by its /64. At
|
||||||
client waits at most a second for its country, and at most 100,000 answers from
|
most 100,000 answers from GeoJS are kept, for 7 days each.
|
||||||
GeoJS are kept, for 7 days each.
|
|
||||||
|
|
||||||
### Settings given as files
|
### Settings given as files
|
||||||
|
|
||||||
@@ -431,12 +456,13 @@ and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
|
|||||||
refused ones included:
|
refused ones included:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"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}
|
{"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","asn":"AS64496","as_name":"Example Net","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}
|
||||||
```
|
```
|
||||||
|
|
||||||
A field that does not apply to a request is left out of its line, apart from
|
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`,
|
`type`, the fields from `time` to `user_agent`, `request_id`, `peer_ip`,
|
||||||
`client_group`, `country`, `action` and `duration_total`, which every line has.
|
`client_group`, `asn`, `as_name`, `country`, `action` and `duration_total`,
|
||||||
|
which every line has.
|
||||||
|
|
||||||
- `time` is when the request arrived, in UTC. `instance` is
|
- `time` is when the request arrived, in UTC. `instance` is
|
||||||
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
||||||
@@ -448,11 +474,14 @@ A field that does not apply to a request is left out of its line, apart from
|
|||||||
`X-Forwarded-For` header as received, several lines of it joined with `, `.
|
`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
|
`client_group` is the client as the rate limits count it: its IPv4 address as
|
||||||
a /32, or the /64 of its IPv6 address.
|
a /32, or the /64 of its IPv6 address.
|
||||||
- `country` is the client's country as GeoJS places it. It is empty with neither
|
- `asn`, `as_name` and `country` are the client's AS number, such as `AS64496`,
|
||||||
country list set, for a client in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, for
|
the name of that AS, and its country, as GeoJS gives them. Each is empty when
|
||||||
a client on a private, loopback or link-local address, when GeoJS cannot place
|
`SWWAF_LOOKUP_SOURCE` is `off`, for a client in `SWWAF_ALLOW_NETS` or
|
||||||
the client or has not answered in time, and for a request whose client a ban
|
`SWWAF_DENY_NETS`, for a client on a private, loopback or link-local address,
|
||||||
covers, even when the client's country is known.
|
when GeoJS has not answered by the time the request went on, and for a request
|
||||||
|
whose client a ban covers, even when the answer is known. `asn` and `as_name`
|
||||||
|
are empty too when GeoJS knows no AS number for the client, which it gives as
|
||||||
|
64512, and `country` when GeoJS cannot place the client.
|
||||||
- `content_type` is the request's `Content-Type`, and `content_length` the
|
- `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.
|
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`
|
- `request_headers` are the request's headers that `SWWAF_LOG_REQUEST_HEADERS`
|
||||||
@@ -593,6 +622,8 @@ is sent on one line:
|
|||||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
||||||
"cause": "limit",
|
"cause": "limit",
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"asn": "",
|
||||||
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"limit": 1000,
|
"limit": 1000,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
@@ -622,8 +653,9 @@ is sent on one line:
|
|||||||
UTC.
|
UTC.
|
||||||
- `client` is the address of the client whose request raised the alert, and
|
- `client` is the address of the client whose request raised the alert, and
|
||||||
`netblock` the netblock of the ban; both are empty for `source_failure` and
|
`netblock` the netblock of the ban; both are empty for `source_failure` and
|
||||||
`file_error`. `asn` and `as_name` are empty until AS numbers are looked up,
|
`file_error`. `asn`, `as_name` and `country` are, for a ban, the client's as
|
||||||
and `country` is, for a ban, the client's country as the ban's notes give it.
|
the ban's notes give them when the alert is raised: empty, as in this alert,
|
||||||
|
when GeoJS had not answered about the client by then.
|
||||||
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`.
|
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`.
|
||||||
- `detail` is what is particular to the event: for a ban, its `cause`, when it
|
- `detail` is what is particular to the event: for a ban, its `cause`, when it
|
||||||
ends as `ban_expires`, in the form the request log gives it, and its `notes`,
|
ends as `ban_expires`, in the form the request log gives it, and its `notes`,
|
||||||
@@ -719,14 +751,15 @@ entries by client address, but for the alerts waiting, with times in UTC.
|
|||||||
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
||||||
when you lifted it, and is left out until you do.
|
when you lifted it, and is left out until you do.
|
||||||
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
- `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
|
and its history: when it was first and last seen, its AS number, AS name and
|
||||||
up and when, its requests, how many were forwarded and how many refused (one
|
country as last looked up and when GeoJS gave them, its requests, how many
|
||||||
`smallwebwaf` answered at its own endpoints is neither, unless it was refused
|
were forwarded and how many refused (one `smallwebwaf` answered at its own
|
||||||
with `401` for a missing or wrong token), the body bytes in each direction,
|
endpoints is neither, unless it was refused with `401` for a missing or wrong
|
||||||
its responses by status class and its offences by kind. Each client is on a
|
token), the body bytes in each direction, its responses by status class and
|
||||||
line of its own, so `grep` shows everything about one.
|
its offences by kind. Each client is on a line of its own, so `grep` shows
|
||||||
- `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and
|
everything about one.
|
||||||
when it was last used.
|
- `lookups.json`: GeoJS's answers, one to a line, each with the client's AS
|
||||||
|
number, AS name and country, when GeoJS gave it and when it was last used.
|
||||||
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
|
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
|
||||||
read: under `cooldowns`, for each event and netblock, or event and `file` or
|
read: under `cooldowns`, for each event and netblock, or event and `file` or
|
||||||
`source`, or event alone, when the last alert was sent, `sent`, and the
|
`source`, or event alone, when the last alert was sent, `sent`, and the
|
||||||
@@ -764,8 +797,8 @@ 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
|
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
||||||
`answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
`answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
||||||
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
||||||
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. The
|
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. An
|
||||||
AS number and AS name come with their lookup.
|
answer's `asn` or `as_name` left out reads as empty.
|
||||||
|
|
||||||
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
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
|
a state file as soon as you save it: what the file then holds replaces what
|
||||||
@@ -934,11 +967,16 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
|
|||||||
series of their own, and the others are counted as `other`. A country that
|
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`;
|
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.
|
one that comes into them gets a series that counts from then on.
|
||||||
|
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
||||||
|
`smallwebwaf_asn_response_bytes_total`, by `asn`, the client's AS number, for
|
||||||
|
the requests whose client's AS number is known, with the `SWWAF_METRICS_TOP_N`
|
||||||
|
busiest AS numbers kept as the countries are.
|
||||||
- `smallwebwaf_geojs_requests_total`: the requests to GeoJS;
|
- `smallwebwaf_geojs_requests_total`: the requests to GeoJS;
|
||||||
`smallwebwaf_geojs_failures_total`: those that failed, an answer that leaves
|
`smallwebwaf_geojs_failures_total`: those that failed, an answer that leaves
|
||||||
out an address asked about included; and `smallwebwaf_geojs_unanswered_total`:
|
out an address asked about included; and `smallwebwaf_geojs_unanswered_total`:
|
||||||
the requests whose client counted as coming from an unknown country because
|
the requests that needed their client's answer, for a country list or
|
||||||
GeoJS had not answered about it in time.
|
`SWWAF_ADD_LOOKUP_HEADERS`, and went on without it because GeoJS had not given
|
||||||
|
it in time.
|
||||||
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
|
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
|
||||||
- `smallwebwaf_state_file_writes_total`,
|
- `smallwebwaf_state_file_writes_total`,
|
||||||
`smallwebwaf_state_file_write_failures_total`,
|
`smallwebwaf_state_file_write_failures_total`,
|
||||||
@@ -989,9 +1027,10 @@ request that carries the token as `Authorization: Bearer <token>`:
|
|||||||
`bans.json` does, and answers `404` when no ban on it is active.
|
`bans.json` does, and answers `404` when no ban on it is active.
|
||||||
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client at
|
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client at
|
||||||
the address `<ip>`: under `client`, the client as `clients.json` holds it,
|
the address `<ip>`: under `client`, the client as `clients.json` holds it,
|
||||||
with its counters and its history, which holds its country as last looked up
|
with its counters and its history, which holds its AS number, AS name and
|
||||||
and its offences, or `null` when the table of clients does not hold it; and
|
country as last looked up and its offences, or `null` when the table of
|
||||||
under `bans`, every ban on a netblock the address is in, with its notes.
|
clients does not hold it; and under `bans`, every ban on a netblock the
|
||||||
|
address is in, with its notes.
|
||||||
|
|
||||||
The ban endpoints answer with the bans listed, made or lifted, under `bans`,
|
The ban endpoints answer with the bans listed, made or lifted, under `bans`,
|
||||||
each as an entry of `bans.json` (see "State files" above), and a ban they make
|
each as an entry of `bans.json` (see "State files" above), and a ban they make
|
||||||
@@ -1247,49 +1286,51 @@ the metrics, failure behaviour and the build order.
|
|||||||
|
|
||||||
## Country and AS number lookup
|
## Country and AS number lookup
|
||||||
|
|
||||||
So far `smallwebwaf` looks up only the country, only through GeoJS, and only
|
`smallwebwaf` looks up the AS number and country of every client through GeoJS,
|
||||||
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
|
a free web service that needs no account and no file, for the request log, the
|
||||||
then the address of every new visitor is sent to GeoJS, except a visitor in
|
client's history, the notes of its bans, their alerts and the metrics, and for
|
||||||
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one whose netblock a ban covers, and
|
the country lists when you set them. This means that GeoJS is told the address
|
||||||
with neither set, none is. An IPv6 visitor is asked about by the first address
|
of every new visitor, whether or not a setting uses the answer, unless you set
|
||||||
of its /64. A new visitor waits at most a second for its answer, and without one
|
`SWWAF_LOOKUP_SOURCE=off`. The only visitors it is not told about are those in
|
||||||
counts as coming from an unknown country until the answer arrives. The addresses
|
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, those whose netblock a ban covers, and
|
||||||
waiting are asked about together, up to 200 in one request, one request at a
|
those on a private, loopback or link-local address. An IPv6 visitor is asked
|
||||||
time; at most 10,000 visitors wait, and one more counts as coming from an
|
about by the first address of its /64. Each answer is kept for seven days, in
|
||||||
unknown country until there is room. While GeoJS fails, visitors with a kept
|
memory and in `lookups.json`, so that it survives a restart, and a visitor whose
|
||||||
answer are unaffected and new ones count as coming from an unknown country.
|
answer is kept is not asked about again.
|
||||||
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
|
A request waits for its client's first answer only while a setting acts on it
|
||||||
client, for the request log, the metrics and the ban notes, and for the country
|
before the request goes on: a country list, or `SWWAF_ADD_LOOKUP_HEADERS`. A new
|
||||||
lists and biased limits when you set them. It works with no setup: by default it
|
visitor then waits up to `SWWAF_LOOKUP_TIMEOUT`, a second by default, and
|
||||||
asks the free GeoJS web service, which needs no account and no file. This means
|
without an answer counts as coming from an unknown country until the answer
|
||||||
that, by default, the address of every new visitor is sent to GeoJS. Each answer
|
arrives. Otherwise no request waits: it goes on at once and is logged without
|
||||||
is kept for seven days, in memory and in `lookups.json`, so that it survives a
|
the answer, which reaches the client's history and the notes of its bans when it
|
||||||
restart, and many addresses are asked about in one request. GeoJS publishes no
|
comes. The addresses waiting are asked about together, up to 200 in one request,
|
||||||
rate limit but may block a caller it thinks asks too much; while it is not
|
one request at a time; at most 10,000 visitors wait, and one more is not asked
|
||||||
answering, new visitors count as coming from an unknown country, which
|
about until there is room, counting meanwhile as coming from an unknown country.
|
||||||
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses.
|
GeoJS publishes no rate limit but may block a caller it thinks asks too much.
|
||||||
|
While GeoJS fails, visitors with a kept answer are unaffected and new ones count
|
||||||
|
as coming from an unknown country, which `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`
|
||||||
|
refuses. 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 from a visitor
|
||||||
|
without an answer.
|
||||||
|
|
||||||
To keep your visitors' addresses on your own host, set
|
To keep your visitors' addresses on your own host, set
|
||||||
`SWWAF_LOOKUP_SOURCE=off`, or use the database file instead of GeoJS:
|
`SWWAF_LOOKUP_SOURCE=off`, or use the database file instead of GeoJS:
|
||||||
`SWWAF_LOOKUP_SOURCE=file` reads the free IPinfo Lite database
|
`SWWAF_LOOKUP_SOURCE=file` reads the free IPinfo Lite database
|
||||||
(`ipinfo_lite.mmdb`). `SWWAF_LOOKUP_SOURCE` comes in milestone 3 or later (see
|
(`ipinfo_lite.mmdb`), and comes with
|
||||||
the build order in [`SPEC.md`](SPEC.md)); until then GeoJS is asked only while a
|
https://git.eeqj.de/sneak/smallwebwaf/issues/22. You download the database with
|
||||||
country list is set. You download the database with your own IPinfo account,
|
your own IPinfo account, mount the directory that holds it into the container,
|
||||||
mount the directory that holds it into the container, point
|
point `SWWAF_LOOKUP_DB_PATH` at the file and refresh it when you choose;
|
||||||
`SWWAF_LOOKUP_DB_PATH` at the file and refresh it when you choose; `smallwebwaf`
|
`smallwebwaf` never downloads it itself, and reads it again when you replace it.
|
||||||
never downloads it itself, and reads it again when you replace it. It has to be
|
It has to be the directory rather than the file itself: docker does not show a
|
||||||
the directory rather than the file itself: docker does not show a single mounted
|
single mounted file being replaced, so a refresh would go unseen. IPinfo
|
||||||
file being replaced, so a refresh would go unseen. IPinfo releases it under the
|
releases it under the Creative Commons Attribution-ShareAlike 4.0 International
|
||||||
Creative Commons Attribution-ShareAlike 4.0 International License and asks for
|
License and asks for attribution, in its own words on https://ipinfo.io/lite:
|
||||||
attribution, in its own words on https://ipinfo.io/lite: "The attribution
|
"The attribution requirements can be met by giving our service credit as your
|
||||||
requirements can be met by giving our service credit as your data source. Simply
|
data source. Simply place a link to IPinfo on the website, application, or
|
||||||
place a link to IPinfo on the website, application, or social media account that
|
social media account that uses our data." Its example of such a credit is a link
|
||||||
uses our data." Its example of such a credit is a link mentioning "IP address
|
mentioning "IP address data is powered by IPinfo". A service that uses the
|
||||||
data is powered by IPinfo". A service that uses the database through
|
database through `smallwebwaf` should carry that link.
|
||||||
`smallwebwaf` should carry that link.
|
|
||||||
|
|
||||||
Neither source can place a private address, so a client on one, such as a
|
Neither source can place a private address, so a client on one, such as a
|
||||||
visitor on your local network, another container or your monitoring, has no
|
visitor on your local network, another container or your monitoring, has no
|
||||||
@@ -1323,8 +1364,9 @@ addresses are never sent to GeoJS.
|
|||||||
and which ban `smallwebwaf` made is dropped when `SWWAF_MAX_BANS` are held.
|
and which ban `smallwebwaf` made is dropped when `SWWAF_MAX_BANS` are held.
|
||||||
- `internal/rules`: reads the rule files at start and again as they change, and
|
- `internal/rules`: reads the rule files at start and again as they change, and
|
||||||
tells which of their rules a request matches.
|
tells which of their rules a request matches.
|
||||||
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
- `internal/lookup`: looks up each client's AS number and country through GeoJS,
|
||||||
answers.
|
keeps the answers, and hands each new one to the proxy, which adds it to the
|
||||||
|
client's history and to the notes of its bans.
|
||||||
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
- `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.
|
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
|
- `internal/state`: reads the state files at start, takes in an admin's edit of
|
||||||
|
|||||||
@@ -137,8 +137,9 @@ type Params struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
// with the fields of the "Alert webhook schema" section of SPEC.md. ASN
|
// with the fields of the "Alert webhook schema" section of SPEC.md. ASN,
|
||||||
// and ASName are empty until AS numbers are looked up.
|
// ASName and Country are, for a ban, the client's as the ban's notes give
|
||||||
|
// them.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // SPEC.md's alert webhook schema names its fields in snake_case
|
//nolint:tagliatelle // SPEC.md's alert webhook schema names its fields in snake_case
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
|
|||||||
+27
-1
@@ -91,7 +91,11 @@ func (b Ban) ActiveAt(now time.Time) bool {
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Notes struct {
|
type Notes struct {
|
||||||
// Country is the client's country, when it was looked up.
|
// ASN, ASName and Country are the client's AS number, AS name and
|
||||||
|
// country, when they were looked up: when the request that caused the
|
||||||
|
// ban was made, or when GeoJS answered about the client afterwards.
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Limit, Window and Count are, for a ban for a broken limit, the limit
|
// Limit, Window and Count are, for a ban for a broken limit, the limit
|
||||||
// that was broken, its window, "minute", "hour" or "day", and the
|
// that was broken, its window, "minute", "hour" or "day", and the
|
||||||
@@ -411,6 +415,28 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
|||||||
return slices.Clone(*bans)
|
return slices.Clone(*bans)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddLookup gives the notes of netblock's bans that have no AS number, AS
|
||||||
|
// name or country yet those of a client in it, as GeoJS answered about
|
||||||
|
// it. It is not a request from netblock, and leaves when it was last seen
|
||||||
|
// unchanged. It does not have bans.json written at once: the notes are
|
||||||
|
// written with its next write, as the counts in them are.
|
||||||
|
func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
bans, found := l.netblocks.Peek(netblock)
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range *bans {
|
||||||
|
notes := &(*bans)[i].Notes
|
||||||
|
if notes.ASN == "" && notes.ASName == "" && notes.Country == "" {
|
||||||
|
notes.ASN, notes.ASName, notes.Country = asn, asName, country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Made returns how many bans for cause have been made since the start:
|
// Made returns how many bans for cause have been made since the start:
|
||||||
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
||||||
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
||||||
|
|||||||
@@ -454,6 +454,45 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
|
|
||||||
|
// A ban made with the client's lookup, one made before it came, after
|
||||||
|
// the first ended, and one on another netblock.
|
||||||
|
ledger.BanForLimit(netblock, midnight(), bans.Notes{
|
||||||
|
ASN: "AS64497", ASName: "Other Net", Country: "FR",
|
||||||
|
})
|
||||||
|
ledger.BanForLimit(netblock, midnight().Add(time.Hour), bans.Notes{})
|
||||||
|
ledger.BanForLimit(other, midnight(), bans.Notes{})
|
||||||
|
|
||||||
|
ledger.AddLookup(netblock, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
held := ledger.Bans(netblock)
|
||||||
|
if len(held) != 2 {
|
||||||
|
t.Fatalf("%s has %d bans, want 2", netblock, len(held))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range []bans.Notes{
|
||||||
|
{ASN: "AS64497", ASName: "Other Net", Country: "FR"},
|
||||||
|
{ASN: "AS64496", ASName: "Example Net", Country: "DE"},
|
||||||
|
} {
|
||||||
|
got := held[i].Notes
|
||||||
|
if got.ASN != want.ASN || got.ASName != want.ASName || got.Country != want.Country {
|
||||||
|
t.Errorf("ban %d's notes give %q, %q and %q, want %q, %q and %q", i+1,
|
||||||
|
got.ASN, got.ASName, got.Country, want.ASN, want.ASName, want.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || notes.Country != "" {
|
||||||
|
t.Errorf("the ban on %s has %q and %q, want neither",
|
||||||
|
other, notes.ASN, notes.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// defaultRules are the rules at the settings' defaults.
|
// defaultRules are the rules at the settings' defaults.
|
||||||
func defaultRules() bans.Rules {
|
func defaultRules() bans.Rules {
|
||||||
return bans.Rules{
|
return bans.Rules{
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ type Config struct {
|
|||||||
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
||||||
// Each starts with /.
|
// Each starts with /.
|
||||||
RateLimitExemptPaths []string
|
RateLimitExemptPaths []string
|
||||||
|
// LookupSource is where each client's AS number and country are
|
||||||
|
// looked up (SWWAF_LOOKUP_SOURCE): geojs, or off for nowhere. A request
|
||||||
|
// waits up to LookupTimeout for its client's first answer while a
|
||||||
|
// setting needs it (SWWAF_LOOKUP_TIMEOUT), which cannot be off.
|
||||||
|
// AddLookupHeaders is true when the app is passed the client's AS
|
||||||
|
// number and country in headers (SWWAF_ADD_LOOKUP_HEADERS).
|
||||||
|
LookupSource string
|
||||||
|
LookupTimeout time.Duration
|
||||||
|
AddLookupHeaders bool
|
||||||
// DeniedCountries are the countries whose clients are refused
|
// DeniedCountries are the countries whose clients are refused
|
||||||
// (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
// (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
||||||
// empty, are the only countries whose clients are let through
|
// empty, are the only countries whose clients are let through
|
||||||
@@ -138,8 +147,8 @@ type Config struct {
|
|||||||
AdminToken string
|
AdminToken string
|
||||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||||
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||||
// MetricsTopN is how many countries get series of their own in the
|
// MetricsTopN is how many AS numbers and how many countries get series
|
||||||
// metrics (SWWAF_METRICS_TOP_N).
|
// of their own in the metrics (SWWAF_METRICS_TOP_N).
|
||||||
MetricsToken string
|
MetricsToken string
|
||||||
MetricsTopN int
|
MetricsTopN int
|
||||||
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
|
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
|
||||||
@@ -233,6 +242,8 @@ var (
|
|||||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||||
"be logged")
|
"be logged")
|
||||||
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
||||||
|
errNotLookupSource = errors.New("is not geojs or off")
|
||||||
|
errNeedsLookups = errors.New("it needs each client looked up")
|
||||||
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
|
||||||
errNotDurationAboveZero = errors.New(
|
errNotDurationAboveZero = errors.New(
|
||||||
"is not a duration above zero, such as 1h or 7d")
|
"is not a duration above zero, such as 1h or 7d")
|
||||||
@@ -300,6 +311,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
||||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
||||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||||
|
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
||||||
|
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"),
|
||||||
|
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
|
||||||
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
@@ -339,13 +353,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
|
|
||||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||||
|
env.checkCountriesAndLookups(cfg)
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
|
||||||
if slices.Contains(cfg.DeniedCountries, country) {
|
|
||||||
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return nil, env.err
|
return nil, env.err
|
||||||
@@ -547,6 +555,47 @@ func (e *environment) countries(name, defaultValue string) []string {
|
|||||||
return countries
|
return countries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupSource reads the setting that is where clients are looked up:
|
||||||
|
// geojs, or off.
|
||||||
|
func (e *environment) lookupSource(name, defaultValue string) string {
|
||||||
|
source := e.value(name, defaultValue)
|
||||||
|
if source != "geojs" && source != off {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", source, errNotLookupSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCountriesAndLookups refuses a country on both country lists, and,
|
||||||
|
// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked
|
||||||
|
// up: the country lists and SWWAF_ADD_LOOKUP_HEADERS.
|
||||||
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
|
if slices.Contains(cfg.DeniedCountries, country) {
|
||||||
|
e.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.LookupSource != off {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, setting := range []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
}{
|
||||||
|
{"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
||||||
|
{"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
|
{"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
|
} {
|
||||||
|
if setting.set {
|
||||||
|
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
|
||||||
|
errNeedsLookups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// headerNames reads a setting that is a list of header names, and
|
// headerNames reads a setting that is a list of header names, and
|
||||||
// returns them in lower case.
|
// returns them in lower case.
|
||||||
func (e *environment) headerNames(name, defaultValue string) []string {
|
func (e *environment) headerNames(name, defaultValue string) []string {
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const (
|
|||||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||||
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||||
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWWAF_BAN_RESPONSE"
|
||||||
@@ -117,6 +120,12 @@ const (
|
|||||||
// off switches a timeout, a size limit or a rate limit off.
|
// off switches a timeout, a size limit or a rate limit off.
|
||||||
const off = "off"
|
const off = "off"
|
||||||
|
|
||||||
|
// enabled is true, as a setting's value.
|
||||||
|
const enabled = "true"
|
||||||
|
|
||||||
|
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE.
|
||||||
|
const defaultLookupSource = "geojs"
|
||||||
|
|
||||||
// environment is a set of environment variables, for FromEnvironment.
|
// environment is a set of environment variables, for FromEnvironment.
|
||||||
type environment map[string]string
|
type environment map[string]string
|
||||||
|
|
||||||
@@ -173,6 +182,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
RulesEnabled: true,
|
RulesEnabled: true,
|
||||||
})
|
})
|
||||||
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
|
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
||||||
|
})
|
||||||
|
|
||||||
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
|
||||||
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
|
||||||
@@ -773,6 +785,50 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupSettingsAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
lookupTimeout: "500ms", addLookupHeaders: enabled,
|
||||||
|
})
|
||||||
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
|
LookupSource: defaultLookupSource, LookupTimeout: 500 * time.Millisecond,
|
||||||
|
AddLookupHeaders: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg = fromEnvironment(t, environment{lookupSource: off})
|
||||||
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
|
LookupSource: off, LookupTimeout: time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
deniedCountries: "kp",
|
||||||
|
allowedCountries: "de",
|
||||||
|
addLookupHeaders: enabled,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(environment{
|
||||||
|
lookupSource: off, name: value,
|
||||||
|
}.lookupEnv)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
||||||
|
!strings.Contains(err.Error(), lookupSource+" is off") {
|
||||||
|
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set empty, the country lists need nothing looked up.
|
||||||
|
fromEnvironment(t, environment{
|
||||||
|
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestSizesAndOff(t *testing.T) {
|
func TestSizesAndOff(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -894,6 +950,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{rateLimitPerHour, "1.5"},
|
{rateLimitPerHour, "1.5"},
|
||||||
{rateLimitPerDay, "-1"}, {rateLimitPerDay, "lots"},
|
{rateLimitPerDay, "-1"}, {rateLimitPerDay, "lots"},
|
||||||
{rateLimitExemptPaths, "/assets/,,/static/"},
|
{rateLimitExemptPaths, "/assets/,,/static/"},
|
||||||
|
{lookupSource, "file"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
|
{addLookupHeaders, "yes"},
|
||||||
{deniedCountries, "nk"},
|
{deniedCountries, "nk"},
|
||||||
{deniedCountries, "kp,,ir"},
|
{deniedCountries, "kp,,ir"},
|
||||||
{deniedCountries, "prk"},
|
{deniedCountries, "prk"},
|
||||||
@@ -1181,6 +1240,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
rateLimitPerHour: "10000",
|
rateLimitPerHour: "10000",
|
||||||
rateLimitPerDay: "50000",
|
rateLimitPerDay: "50000",
|
||||||
rateLimitExemptPaths: "",
|
rateLimitExemptPaths: "",
|
||||||
|
lookupSource: defaultLookupSource,
|
||||||
|
lookupTimeout: "1s",
|
||||||
|
addLookupHeaders: "false",
|
||||||
deniedCountries: "",
|
deniedCountries: "",
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
banResponse: "403",
|
banResponse: "403",
|
||||||
@@ -1242,6 +1304,18 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
|
|||||||
wantBanSettings(t, got, want)
|
wantBanSettings(t, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantLookupSettings checks the settings for lookups.
|
||||||
|
func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got.LookupSource != want.LookupSource || got.LookupTimeout != want.LookupTimeout ||
|
||||||
|
got.AddLookupHeaders != want.AddLookupHeaders {
|
||||||
|
t.Errorf("lookups from %q, waited for %s, headers %t; want %q, %s, %t",
|
||||||
|
got.LookupSource, got.LookupTimeout, got.AddLookupHeaders,
|
||||||
|
want.LookupSource, want.LookupTimeout, want.AddLookupHeaders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// wantBanSettings checks the settings for bans, the state files, the
|
// wantBanSettings checks the settings for bans, the state files, the
|
||||||
// metrics and the rule files.
|
// metrics and the rule files.
|
||||||
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
|
||||||
|
|||||||
+124
-64
@@ -1,7 +1,7 @@
|
|||||||
// Package lookup looks up each client's country through the GeoJS web
|
// Package lookup looks up each client's AS number and country through
|
||||||
// service, and keeps the answers in memory, for at most 100,000 clients
|
// the GeoJS web service, and keeps the answers in memory, for at most
|
||||||
// and for 7 days each. The answers are written to lookups.json and read
|
// 100,000 clients and for 7 days each. The answers are written to
|
||||||
// from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,9 +24,10 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL is GeoJS's country endpoint. Asked about several addresses at once,
|
// URL is GeoJS's endpoint for an address's place and network. Asked about
|
||||||
// comma separated in its ip parameter, it answers with a list.
|
// several addresses at once, comma separated in its ip parameter, it
|
||||||
const URL = "https://get.geojs.io/v1/ip/country.json"
|
// answers with a list.
|
||||||
|
const URL = "https://get.geojs.io/v1/ip/geo.json"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// keepFor is how long an answer is used instead of asking GeoJS again.
|
// keepFor is how long an answer is used instead of asking GeoJS again.
|
||||||
@@ -40,9 +42,8 @@ const (
|
|||||||
maxWaiting = 10000
|
maxWaiting = 10000
|
||||||
// maxPerRequest is how many addresses one request to GeoJS asks about.
|
// maxPerRequest is how many addresses one request to GeoJS asks about.
|
||||||
maxPerRequest = 200
|
maxPerRequest = 200
|
||||||
// timeout is how long a new client waits for its answer, and how long
|
// unknownASN is the AS number GeoJS gives when it knows none.
|
||||||
// a request to GeoJS may take before it is abandoned.
|
unknownASN = 64512
|
||||||
timeout = time.Second
|
|
||||||
// After a failure GeoJS is not asked again for a second, and for
|
// After a failure GeoJS is not asked again for a second, and for
|
||||||
// retryDelayFactor times as long after each further failure in a row,
|
// retryDelayFactor times as long after each further failure in a row,
|
||||||
// up to five minutes.
|
// up to five minutes.
|
||||||
@@ -62,6 +63,16 @@ var (
|
|||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is where GeoJS is asked, normally URL.
|
// URL is where GeoJS is asked, normally URL.
|
||||||
URL string
|
URL string
|
||||||
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
|
// (SWWAF_LOOKUP_TIMEOUT).
|
||||||
|
Timeout time.Duration
|
||||||
|
// Wait is true when a setting needs each request's answer before the
|
||||||
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
Wait bool
|
||||||
|
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
||||||
|
// kept.
|
||||||
|
Answered func(Answer)
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// ProcessLog receives GeoJS's failures.
|
// ProcessLog receives GeoJS's failures.
|
||||||
@@ -73,11 +84,14 @@ type Params struct {
|
|||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeoJS looks up clients' countries through GeoJS. At most one request
|
// GeoJS looks up clients' AS numbers and countries through GeoJS. At most
|
||||||
// to GeoJS is under way at a time, and it asks about every client waiting,
|
// one request to GeoJS is under way at a time, and it asks about every
|
||||||
// up to maxPerRequest. It is safe for concurrent use.
|
// client waiting, up to maxPerRequest. It is safe for concurrent use.
|
||||||
type GeoJS struct {
|
type GeoJS struct {
|
||||||
url string
|
url string
|
||||||
|
timeout time.Duration
|
||||||
|
wait bool
|
||||||
|
answered func(Answer)
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
processLog *slog.Logger
|
processLog *slog.Logger
|
||||||
metrics *metrics.Metrics
|
metrics *metrics.Metrics
|
||||||
@@ -100,10 +114,16 @@ type GeoJS struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Answer is what GeoJS said about a client, as lookups.json holds it: its
|
// 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
|
// AS number, such as AS64496, and the AS's name, both "" when GeoJS knows
|
||||||
// the answer was last used.
|
// no AS number for it; its country, "" when GeoJS cannot place it; when
|
||||||
|
// GeoJS said so, and when the answer was last used. The zero Answer is
|
||||||
|
// that of a client with no answer.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Answer struct {
|
type Answer struct {
|
||||||
Client netip.Prefix `json:"client"`
|
Client netip.Prefix `json:"client"`
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Answered time.Time `json:"answered"`
|
Answered time.Time `json:"answered"`
|
||||||
Used time.Time `json:"used"`
|
Used time.Time `json:"used"`
|
||||||
@@ -128,6 +148,9 @@ func New(params Params) *GeoJS {
|
|||||||
|
|
||||||
return &GeoJS{
|
return &GeoJS{
|
||||||
url: params.URL,
|
url: params.URL,
|
||||||
|
timeout: params.Timeout,
|
||||||
|
wait: params.Wait,
|
||||||
|
answered: params.Answered,
|
||||||
now: params.Now,
|
now: params.Now,
|
||||||
processLog: params.ProcessLog,
|
processLog: params.ProcessLog,
|
||||||
metrics: params.Metrics,
|
metrics: params.Metrics,
|
||||||
@@ -142,23 +165,23 @@ func New(params Params) *GeoJS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Country returns the country GeoJS places client in, as a two-letter
|
// LookUp returns the answer GeoJS gave about client, with its country as
|
||||||
// code in capitals, or "" when the country cannot be found: GeoJS cannot
|
// a two-letter code in capitals, or the zero Answer when there is none
|
||||||
// place the client, or has not answered in time. An answer is kept for 7
|
// yet. An answer is kept for 7 days. Without one, the client is asked
|
||||||
// days. Without one, a client waits up to timeout for it, unless it has
|
// about in the background, and, while Wait is set, the request waits up
|
||||||
// gone without one before; until GeoJS answers, the client is asked about
|
// to Timeout for the answer, unless the client has gone without one
|
||||||
// again in the background. ctx is the context of the client's request,
|
// before. ctx is the context of the client's request, and ends the wait
|
||||||
// and ends the wait when it ends.
|
// when it ends.
|
||||||
//
|
//
|
||||||
// GeoJS is asked about the client's first address, which is the client's
|
// GeoJS is asked about the client's first address, which is the client's
|
||||||
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
||||||
func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||||
country, asked := g.answerOrWait(ctx, client)
|
answer, asked := g.answerOrWait(ctx, client)
|
||||||
if asked == nil {
|
if asked == nil {
|
||||||
return country
|
return answer
|
||||||
}
|
}
|
||||||
|
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(g.timeout)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -170,7 +193,7 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
|||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
country, found := g.kept(client)
|
answer, found := g.kept(client)
|
||||||
if !found {
|
if !found {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
}
|
}
|
||||||
@@ -180,7 +203,15 @@ func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
|||||||
w.late = true
|
w.late = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return country
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept returns client's answer, if one is kept, without asking GeoJS.
|
||||||
|
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
return g.kept(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
||||||
@@ -232,13 +263,13 @@ func (g *GeoJS) Load(answers []Answer) {
|
|||||||
// nil when there is nothing to wait for.
|
// nil when there is nothing to wait for.
|
||||||
func (g *GeoJS) answerOrWait(
|
func (g *GeoJS) answerOrWait(
|
||||||
ctx context.Context, client netip.Prefix,
|
ctx context.Context, client netip.Prefix,
|
||||||
) (string, <-chan struct{}) {
|
) (Answer, <-chan struct{}) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
country, found := g.kept(client)
|
answer, found := g.kept(client)
|
||||||
if found {
|
if found {
|
||||||
return country, nil
|
return answer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w, waiting := g.waiting[client]
|
w, waiting := g.waiting[client]
|
||||||
@@ -249,10 +280,14 @@ func (g *GeoJS) answerOrWait(
|
|||||||
|
|
||||||
g.ask(ctx)
|
g.ask(ctx)
|
||||||
|
|
||||||
|
if !g.wait {
|
||||||
|
return Answer{}, nil // the answer is not needed before the request goes on
|
||||||
|
}
|
||||||
|
|
||||||
if w == nil {
|
if w == nil {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
|
|
||||||
return "", nil // too many clients wait already
|
return Answer{}, nil // too many clients wait already
|
||||||
}
|
}
|
||||||
|
|
||||||
if !g.asking {
|
if !g.asking {
|
||||||
@@ -263,25 +298,25 @@ func (g *GeoJS) answerOrWait(
|
|||||||
if w.late {
|
if w.late {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.GeoJSUnanswered.Inc()
|
||||||
|
|
||||||
return "", nil
|
return Answer{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", w.asked
|
return Answer{}, w.asked
|
||||||
}
|
}
|
||||||
|
|
||||||
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
||||||
// and notes that it was used.
|
// and notes that it was used.
|
||||||
func (g *GeoJS) kept(client netip.Prefix) (string, bool) {
|
func (g *GeoJS) kept(client netip.Prefix) (Answer, bool) {
|
||||||
now := g.now()
|
now := g.now()
|
||||||
|
|
||||||
kept, found := g.answers.Get(client)
|
kept, found := g.answers.Get(client)
|
||||||
if !found || now.Sub(kept.Answered) >= keepFor {
|
if !found || now.Sub(kept.Answered) >= keepFor {
|
||||||
return "", false
|
return Answer{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
kept.Used = now
|
kept.Used = now
|
||||||
|
|
||||||
return kept.Country, true
|
return *kept, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask starts asking GeoJS about the waiting clients, unless a request to
|
// ask starts asking GeoJS about the waiting clients, unless a request to
|
||||||
@@ -299,7 +334,8 @@ func (g *GeoJS) ask(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// askAboutWaiting asks GeoJS about the waiting clients, one request at a
|
// askAboutWaiting asks GeoJS about the waiting clients, one request at a
|
||||||
// time, until none is left or GeoJS fails.
|
// time, until none is left or GeoJS fails. Each answer kept is given to
|
||||||
|
// Answered, outside the lock, since Answered takes locks of its own.
|
||||||
func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
clients := g.nextClients()
|
clients := g.nextClients()
|
||||||
@@ -307,8 +343,16 @@ func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
countries, err := g.request(ctx, clients)
|
given, err := g.request(ctx, clients)
|
||||||
if !g.keep(clients, countries, err) {
|
kept, answered := g.keep(clients, given, err)
|
||||||
|
|
||||||
|
if g.answered != nil {
|
||||||
|
for _, answer := range kept {
|
||||||
|
g.answered(answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !answered {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,32 +384,35 @@ func (g *GeoJS) nextClients() []netip.Prefix {
|
|||||||
return clients
|
return clients
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep notes how a request to GeoJS about clients ended, and reports
|
// keep notes how a request to GeoJS about clients ended, given being the
|
||||||
// whether GeoJS answered about all of them. Each client whose address
|
// answer for each address GeoJS's answer names. It returns the answers it
|
||||||
// GeoJS's answer names gets its answer, with no country when GeoJS gave
|
// kept, and reports whether GeoJS answered about all of the clients. Each
|
||||||
// none. An answer that leaves an address out is a failure. After a
|
// client whose address GeoJS's answer names gets its answer. An answer
|
||||||
// failure GeoJS is left alone for a while, and every client still waiting
|
// that leaves an address out is a failure. After a failure GeoJS is left
|
||||||
// stops waiting and is asked about once GeoJS is asked again.
|
// alone for a while, and every client still waiting stops waiting and is
|
||||||
|
// asked about once GeoJS is asked again.
|
||||||
func (g *GeoJS) keep(
|
func (g *GeoJS) keep(
|
||||||
clients []netip.Prefix, countries map[netip.Addr]string, err error,
|
clients []netip.Prefix, given map[netip.Addr]Answer, err error,
|
||||||
) bool {
|
) ([]Answer, bool) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
now := g.now()
|
now := g.now()
|
||||||
|
kept := make([]Answer, 0, len(clients))
|
||||||
leftOut := 0
|
leftOut := 0
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
country, named := countries[client.Addr()]
|
answer, named := given[client.Addr()]
|
||||||
if !named {
|
if !named {
|
||||||
leftOut++
|
leftOut++
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
g.answers.Add(client, &Answer{
|
answer.Client, answer.Answered, answer.Used = client, now, now
|
||||||
Client: client, Country: country, Answered: now, Used: now,
|
g.answers.Add(client, &answer)
|
||||||
})
|
kept = append(kept, answer)
|
||||||
|
|
||||||
close(g.waiting[client].asked)
|
close(g.waiting[client].asked)
|
||||||
delete(g.waiting, client)
|
delete(g.waiting, client)
|
||||||
}
|
}
|
||||||
@@ -400,26 +447,28 @@ func (g *GeoJS) keep(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return false
|
return kept, false
|
||||||
}
|
}
|
||||||
|
|
||||||
g.retryDelay = 0
|
g.retryDelay = 0
|
||||||
|
|
||||||
return true
|
return kept, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// request asks GeoJS about clients in one request, and returns the
|
// request asks GeoJS about clients in one request, and returns the answer
|
||||||
// country it gave, in capitals, for each address its answer names.
|
// for each address GeoJS's answer names: its AS number and the AS's name,
|
||||||
|
// both "" for the AS number 64512, which GeoJS gives when it knows none,
|
||||||
|
// and its country, in capitals.
|
||||||
func (g *GeoJS) request(
|
func (g *GeoJS) request(
|
||||||
ctx context.Context, clients []netip.Prefix,
|
ctx context.Context, clients []netip.Prefix,
|
||||||
) (map[netip.Addr]string, error) {
|
) (map[netip.Addr]Answer, error) {
|
||||||
addrs := make([]string, 0, len(clients))
|
addrs := make([]string, 0, len(clients))
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
addrs = append(addrs, client.Addr().String())
|
addrs = append(addrs, client.Addr().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, g.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
|
||||||
@@ -446,9 +495,12 @@ func (g *GeoJS) request(
|
|||||||
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
var answers []struct {
|
var answers []struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Country string `json:"country"`
|
ASN int64 `json:"asn"`
|
||||||
|
ASName string `json:"organization_name"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
|
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
|
||||||
@@ -456,14 +508,22 @@ func (g *GeoJS) request(
|
|||||||
return nil, fmt.Errorf("read GeoJS's answer: %w", err)
|
return nil, fmt.Errorf("read GeoJS's answer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
countries := make(map[netip.Addr]string, len(answers))
|
given := make(map[netip.Addr]Answer, len(answers))
|
||||||
|
|
||||||
for _, item := range answers {
|
for _, item := range answers {
|
||||||
addr, err := netip.ParseAddr(item.IP)
|
addr, err := netip.ParseAddr(item.IP)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
countries[addr] = strings.ToUpper(item.Country)
|
continue
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return countries, nil
|
answer := Answer{Country: strings.ToUpper(item.CountryCode)}
|
||||||
|
if item.ASN != 0 && item.ASN != unknownASN {
|
||||||
|
answer.ASN = "AS" + strconv.FormatInt(item.ASN, 10)
|
||||||
|
answer.ASName = item.ASName
|
||||||
|
}
|
||||||
|
|
||||||
|
given[addr] = answer
|
||||||
|
}
|
||||||
|
|
||||||
|
return given, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-11
@@ -23,9 +23,13 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// germany is where the stand-in for GeoJS places every address but
|
// germany is where the stand-in for GeoJS places every address but
|
||||||
// unplaced.
|
// unplaced, and asNumber, kept as asn, and asName the AS it gives them.
|
||||||
germany = "DE"
|
germany = "DE"
|
||||||
// unplaced is the address it cannot place.
|
asNumber = 64496
|
||||||
|
asn = "AS64496"
|
||||||
|
asName = "Example Net"
|
||||||
|
// unplaced is the address it cannot place, for which it gives the AS
|
||||||
|
// number 64512 and the AS name Unknown, as GeoJS does.
|
||||||
unplaced = "192.0.2.1"
|
unplaced = "192.0.2.1"
|
||||||
// leftOut is the address it leaves out of its answer when
|
// leftOut is the address it leaves out of its answer when
|
||||||
// answeringWithoutLeftOut.
|
// answeringWithoutLeftOut.
|
||||||
@@ -83,7 +87,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
|||||||
|
|
||||||
var earlier sync.WaitGroup
|
var earlier sync.WaitGroup
|
||||||
|
|
||||||
earlier.Go(func() { g.Country(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
earlier.Go(func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||||
defer earlier.Wait()
|
defer earlier.Wait()
|
||||||
|
|
||||||
waitForRequests(t, geojs, 1)
|
waitForRequests(t, geojs, 1)
|
||||||
@@ -115,6 +119,58 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// A timeout longer than the default second, and a GeoJS that does
|
||||||
|
// not answer.
|
||||||
|
const longerTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
g := lookup.New(lookup.Params{
|
||||||
|
URL: lookup.URL,
|
||||||
|
Timeout: longerTimeout,
|
||||||
|
Wait: true,
|
||||||
|
Now: time.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Metrics: m,
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
|
})
|
||||||
|
g.SetTransport(&standIn{answers: hanging})
|
||||||
|
|
||||||
|
var (
|
||||||
|
request sync.WaitGroup
|
||||||
|
waited time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
request.Go(func() {
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||||
|
|
||||||
|
waited = time.Since(began)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A moment before the timeout runs out, GeoJS is still being asked:
|
||||||
|
// the request to it has not failed.
|
||||||
|
time.Sleep(longerTimeout - time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
wantFailures(t, m, 0)
|
||||||
|
|
||||||
|
// As it runs out, the client's request goes on, and the request to
|
||||||
|
// GeoJS is abandoned, which counts as a failure.
|
||||||
|
request.Wait()
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
if waited != longerTimeout {
|
||||||
|
t.Errorf("waited %s for the answer, want %s", waited, longerTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFailures(t, m, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -187,6 +243,96 @@ func TestCountryIsKeptInCapitals(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnswerHoldsTheASNumberTheASNameAndTheCountry(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")
|
||||||
|
now := clock.Now()
|
||||||
|
|
||||||
|
// For the client it cannot place, GeoJS gives the AS number 64512
|
||||||
|
// and the AS name Unknown, which count as unknown.
|
||||||
|
for client, want := range map[netip.Prefix]lookup.Answer{
|
||||||
|
placed: {
|
||||||
|
Client: placed, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: now, Used: now,
|
||||||
|
},
|
||||||
|
notPlaced: {Client: notPlaced, Answered: now, Used: now},
|
||||||
|
} {
|
||||||
|
got := g.LookUp(t.Context(), client)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("answer for %s\n%+v\nwant\n%+v", client, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
given []lookup.Answer
|
||||||
|
)
|
||||||
|
|
||||||
|
geojs := &standIn{answers: answeringSlowly}
|
||||||
|
clock := newClock()
|
||||||
|
m := metrics.New(1, "app")
|
||||||
|
g := lookup.New(lookup.Params{
|
||||||
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Answered: func(answer lookup.Answer) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
given = append(given, answer)
|
||||||
|
},
|
||||||
|
Now: clock.Now,
|
||||||
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
|
Metrics: m,
|
||||||
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
|
})
|
||||||
|
g.SetTransport(geojs)
|
||||||
|
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
|
// The request goes on at once, without an answer, and GeoJS is asked
|
||||||
|
// about the client, which it answers most of a second later.
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
got := g.LookUp(t.Context(), client)
|
||||||
|
if took := time.Since(began); took != 0 || got != (lookup.Answer{}) {
|
||||||
|
t.Errorf("waited %s for %+v, want no wait and no answer", took, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRequests(t, geojs, 1)
|
||||||
|
wantAsked(t, geojs, 0, "203.0.113.9")
|
||||||
|
|
||||||
|
time.Sleep(timeout)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
now := clock.Now()
|
||||||
|
want := lookup.Answer{
|
||||||
|
Client: client, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: now, Used: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if !slices.Equal(given, []lookup.Answer{want}) {
|
||||||
|
t.Errorf("answers given %+v, want only %+v", given, want)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
wantCountry(t, g, client, germany)
|
||||||
|
wantUnanswered(t, m, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -197,6 +343,8 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
|||||||
geojs := &standIn{answers: hanging}
|
geojs := &standIn{answers: hanging}
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||||
Metrics: metrics.New(1, "app"),
|
Metrics: metrics.New(1, "app"),
|
||||||
@@ -401,6 +549,8 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
|||||||
m := metrics.New(1, "app")
|
m := metrics.New(1, "app")
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
ProcessLog: slog.New(slog.DiscardHandler),
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
@@ -494,21 +644,24 @@ func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list := make([]map[string]string, 0, len(addrs))
|
list := make([]map[string]any, 0, len(addrs))
|
||||||
|
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
country := germany
|
item := map[string]any{
|
||||||
|
"ip": addr, "asn": asNumber, "organization_name": asName,
|
||||||
|
"country_code": germany,
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case addr == unplaced:
|
case addr == unplaced:
|
||||||
country = ""
|
item = map[string]any{"ip": addr, "asn": 64512, "organization_name": "Unknown"}
|
||||||
case addr == leftOut && answers == answeringWithoutLeftOut:
|
case addr == leftOut && answers == answeringWithoutLeftOut:
|
||||||
continue
|
continue
|
||||||
case answers == answeringInLowerCase:
|
case answers == answeringInLowerCase:
|
||||||
country = strings.ToLower(germany)
|
item["country_code"] = strings.ToLower(germany)
|
||||||
}
|
}
|
||||||
|
|
||||||
list = append(list, map[string]string{"ip": addr, "country": country})
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
var answer any = list
|
var answer any = list
|
||||||
@@ -566,7 +719,8 @@ func (c *testClock) advance(d time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
||||||
// asking the stand-in by that clock.
|
// asking the stand-in by that clock, for which a request waits for its
|
||||||
|
// client's first answer.
|
||||||
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
||||||
geojs, clock, g, _ := startWithAlerts()
|
geojs, clock, g, _ := startWithAlerts()
|
||||||
|
|
||||||
@@ -578,7 +732,7 @@ func start() (*standIn, *testClock, *lookup.GeoJS) {
|
|||||||
// cooldown, by the same clock.
|
// cooldown, by the same clock.
|
||||||
func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
||||||
geojs := &standIn{}
|
geojs := &standIn{}
|
||||||
clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
clock := newClock()
|
||||||
queue := alerts.New(alerts.Params{
|
queue := alerts.New(alerts.Params{
|
||||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||||
Events: alerts.Events(),
|
Events: alerts.Events(),
|
||||||
@@ -587,6 +741,8 @@ func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
|||||||
})
|
})
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Wait: true,
|
||||||
Now: clock.Now,
|
Now: clock.Now,
|
||||||
ProcessLog: slog.New(slog.DiscardHandler),
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
Metrics: metrics.New(1, "app"),
|
Metrics: metrics.New(1, "app"),
|
||||||
@@ -597,6 +753,11 @@ func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
|||||||
return geojs, clock, g, queue
|
return geojs, clock, g, queue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newClock returns a clock set to the start of a day.
|
||||||
|
func newClock() *testClock {
|
||||||
|
return &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
||||||
|
}
|
||||||
|
|
||||||
// newClients returns what returns a new IPv4 client each time it is
|
// newClients returns what returns a new IPv4 client each time it is
|
||||||
// called.
|
// called.
|
||||||
func newClients() func() netip.Prefix {
|
func newClients() func() netip.Prefix {
|
||||||
@@ -613,7 +774,7 @@ func newClients() func() netip.Prefix {
|
|||||||
func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
|
func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
got := g.Country(t.Context(), client)
|
got := g.LookUp(t.Context(), client).Country
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("%s is in %q, want %q", client, got, want)
|
t.Errorf("%s is in %q, want %q", client, got, want)
|
||||||
}
|
}
|
||||||
@@ -658,6 +819,16 @@ func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantFailures checks how many requests to GeoJS m counts as failed.
|
||||||
|
func wantFailures(t *testing.T, m *metrics.Metrics, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := testutil.ToFloat64(m.GeoJSFailures)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%v requests to GeoJS failed, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// waitForRequests waits until g has done all it can before time passes,
|
// waitForRequests waits until g has done all it can before time passes,
|
||||||
// checks that GeoJS has had count requests, and returns the addresses each
|
// checks that GeoJS has had count requests, and returns the addresses each
|
||||||
// asked about.
|
// asked about.
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) {
|
|||||||
wantCountry(t, g, placed, germany)
|
wantCountry(t, g, placed, germany)
|
||||||
|
|
||||||
want := []lookup.Answer{
|
want := []lookup.Answer{
|
||||||
{Client: notPlaced, Country: "", Answered: asked, Used: asked},
|
{Client: notPlaced, Answered: asked, Used: asked},
|
||||||
{Client: placed, Country: germany, Answered: asked, Used: asked.Add(time.Hour)},
|
{
|
||||||
|
Client: placed, ASN: asn, ASName: asName, Country: germany,
|
||||||
|
Answered: asked, Used: asked.Add(time.Hour),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if got := g.Snapshot(); !slices.Equal(got, want) {
|
if got := g.Snapshot(); !slices.Equal(got, want) {
|
||||||
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
|
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// other is the label value under which the countries or AS numbers
|
||||||
|
// outside the busiest are counted.
|
||||||
|
const other = "other"
|
||||||
|
|
||||||
|
// busiest are the metrics by one thing the lookup finds of the client,
|
||||||
|
// its country or its AS number, for requests whose client's is known.
|
||||||
|
// The topN busiest countries or AS numbers, 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. One 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 busiest struct {
|
||||||
|
topN int
|
||||||
|
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
requestBytes *prometheus.CounterVec
|
||||||
|
responseBytes *prometheus.CounterVec
|
||||||
|
// refused are, by country, the requests the country lists refused; nil
|
||||||
|
// by AS number.
|
||||||
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// seen is each country's or AS number's requests since the start, by
|
||||||
|
// which they are ranked.
|
||||||
|
seen map[string]int64
|
||||||
|
// top are those 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) *busiest {
|
||||||
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
|
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
||||||
|
"Requests the country lists refused, by the client's country.",
|
||||||
|
[]string{"country"})
|
||||||
|
|
||||||
|
return countries
|
||||||
|
}
|
||||||
|
|
||||||
|
// newASNs returns the metrics by AS number, with series of their own for
|
||||||
|
// the topN busiest AS numbers.
|
||||||
|
func newASNs(topN int) *busiest {
|
||||||
|
return newBusiest(topN, "asn", "the client's AS number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBusiest returns the metrics by label, which is described as
|
||||||
|
// description, with series of their own for the topN busiest values.
|
||||||
|
func newBusiest(topN int, label, description string) *busiest {
|
||||||
|
by := []string{label}
|
||||||
|
|
||||||
|
return &busiest{
|
||||||
|
topN: topN,
|
||||||
|
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
||||||
|
"Requests, by "+description+".", by),
|
||||||
|
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
||||||
|
"Request body bytes, by "+description+".", by),
|
||||||
|
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
||||||
|
"Response body bytes, by "+description+".", by),
|
||||||
|
seen: map[string]int64{},
|
||||||
|
top: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe and Collect make the metrics a prometheus.Collector, so that
|
||||||
|
// they are registered together.
|
||||||
|
func (b *busiest) Describe(ch chan<- *prometheus.Desc) {
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.Describe(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect is the other half of prometheus.Collector, with Describe.
|
||||||
|
func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.Collect(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
|
// country, the refusals by the country lists as well.
|
||||||
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
|
if b.refused != nil {
|
||||||
|
vecs = append(vecs, b.refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vecs
|
||||||
|
}
|
||||||
|
|
||||||
|
// add counts a request from its log line, whose client's country or AS
|
||||||
|
// number, value, is known.
|
||||||
|
func (b *busiest) add(value string, line *requestlog.Line) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.seen[value]++
|
||||||
|
|
||||||
|
label := b.label(value)
|
||||||
|
b.requests.WithLabelValues(label).Inc()
|
||||||
|
b.requestBytes.WithLabelValues(label).Add(float64(line.RequestBytes))
|
||||||
|
b.responseBytes.WithLabelValues(label).Add(float64(line.ResponseBytes))
|
||||||
|
|
||||||
|
if b.refused != nil && line.Action == requestlog.ActionCountryDenied {
|
||||||
|
b.refused.WithLabelValues(label).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// label returns the label a request from value is counted under: value
|
||||||
|
// while it is one of the busiest, other while it is not. A value busier
|
||||||
|
// than the least busy of them takes its place, and that one's series are
|
||||||
|
// dropped.
|
||||||
|
func (b *busiest) label(value string) string {
|
||||||
|
if b.top[value] {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b.top) < b.topN {
|
||||||
|
b.top[value] = true
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
least := ""
|
||||||
|
|
||||||
|
for top := range b.top {
|
||||||
|
if least == "" || b.seen[top] < b.seen[least] {
|
||||||
|
least = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.seen[value] <= b.seen[least] {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.top, least)
|
||||||
|
|
||||||
|
for _, vec := range b.vecs() {
|
||||||
|
vec.DeleteLabelValues(least)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.top[value] = true
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+16
-12
@@ -36,12 +36,13 @@ type Metrics struct {
|
|||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
// ruleMatches are made by AddRules.
|
// ruleMatches are made by AddRules.
|
||||||
ruleMatches *prometheus.CounterVec
|
ruleMatches *prometheus.CounterVec
|
||||||
countries *countries
|
countries *busiest
|
||||||
|
asns *busiest
|
||||||
|
|
||||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||||
// that failed. GeoJSUnanswered are the requests whose client counted
|
// that failed. GeoJSUnanswered are the requests that needed their
|
||||||
// as coming from an unknown country because GeoJS had not answered
|
// client's answer, for a setting that acts on it, and went on without
|
||||||
// about it in time.
|
// it because GeoJS had not given it in time.
|
||||||
GeoJSRequests prometheus.Counter
|
GeoJSRequests prometheus.Counter
|
||||||
GeoJSFailures prometheus.Counter
|
GeoJSFailures prometheus.Counter
|
||||||
GeoJSUnanswered prometheus.Counter
|
GeoJSUnanswered prometheus.Counter
|
||||||
@@ -55,8 +56,8 @@ type Metrics struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries get series of their own
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
byStatus := []string{"status_class", "action"}
|
byStatus := []string{"status_class", "action"}
|
||||||
@@ -96,6 +97,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
offences: counterVec("smallwebwaf_offences_total",
|
offences: counterVec("smallwebwaf_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_requests_total",
|
Name: "smallwebwaf_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
@@ -106,8 +108,8 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_unanswered_total",
|
Name: "smallwebwaf_geojs_unanswered_total",
|
||||||
Help: "Requests whose client counted as coming from an unknown " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"country because GeoJS had not answered about it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
@@ -129,9 +131,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||||
m.requestDuration, m.upstreamDuration,
|
m.requestDuration, m.upstreamDuration,
|
||||||
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences,
|
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.countries, m.asns,
|
||||||
m.countries.requests, m.countries.requestBytes, m.countries.responseBytes,
|
|
||||||
m.countries.refused,
|
|
||||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||||
m.stateFileWrites, m.stateFileWriteFailures,
|
m.stateFileWrites, m.stateFileWriteFailures,
|
||||||
m.stateFileLastWrite, m.stateFileSize,
|
m.stateFileLastWrite, m.stateFileSize,
|
||||||
@@ -315,7 +315,11 @@ func (m *Metrics) RequestEnded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
m.countries.add(line)
|
m.countries.add(line.Country, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.ASN != "" {
|
||||||
|
m.asns.add(line.ASN, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ func (rq *request) limitBroken(now time.Time) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Limit: hit.Limit,
|
Limit: hit.Limit,
|
||||||
Window: hit.Window,
|
Window: hit.Window,
|
||||||
@@ -103,6 +105,8 @@ func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
RuleID: rule.ID,
|
RuleID: rule.ID,
|
||||||
Target: rule.Target,
|
Target: rule.Target,
|
||||||
@@ -165,6 +169,8 @@ func (rq *request) alertBan(ban bans.Ban) {
|
|||||||
Event: event,
|
Event: event,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: ban.Netblock,
|
Netblock: ban.Netblock,
|
||||||
|
ASN: ban.Notes.ASN,
|
||||||
|
ASName: ban.Notes.ASName,
|
||||||
Country: ban.Notes.Country,
|
Country: ban.Notes.Country,
|
||||||
Reason: ban.Reason,
|
Reason: ban.Reason,
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ func TestBanNotes(t *testing.T) {
|
|||||||
Cause: bans.CauseLimit,
|
Cause: bans.CauseLimit,
|
||||||
Reason: "requests per minute over the limit of 1",
|
Reason: "requests per minute over the limit of 1",
|
||||||
Notes: bans.Notes{
|
Notes: bans.Notes{
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
Window: minute,
|
Window: minute,
|
||||||
@@ -362,8 +364,9 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
|
|
||||||
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
||||||
// with the settings in env on top of trusting localhost's
|
// with the settings in env on top of trusting localhost's
|
||||||
// X-Forwarded-For, clients' countries looked up at geojsURL, and a clock
|
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||||
// set to midnight, the start of a bucket in every window.
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
|
// window.
|
||||||
func startWithClock(
|
func startWithClock(
|
||||||
t *testing.T, geojsURL string, env map[string]string,
|
t *testing.T, geojsURL string, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server) {
|
) (*sender, *clock, *proxy.Server) {
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countryDenied reports whether the country lists refuse the request.
|
// countryDenied reports whether the country lists refuse the request, by
|
||||||
// The client's country is looked up only while a list is set, and never
|
// the client's country as it was looked up. A client without a country,
|
||||||
// for a client on a private, loopback or link-local address, which has
|
// or whose country cannot be found, is refused only by
|
||||||
// no country. A client without a country, or whose country cannot be
|
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
// found, is refused only by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. ctx is
|
func (rq *request) countryDenied() bool {
|
||||||
// the request's own context.
|
|
||||||
func (rq *request) countryDenied(ctx context.Context) bool {
|
|
||||||
denied := rq.h.config.DeniedCountries
|
denied := rq.h.config.DeniedCountries
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
country := rq.line.Country
|
||||||
if len(denied) == 0 && len(allowed) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var country string
|
|
||||||
if hasCountry(rq.client) {
|
|
||||||
country = rq.h.geojs.Country(ctx, clientGroup(rq.client))
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.Country = country
|
|
||||||
|
|
||||||
if slices.Contains(denied, country) {
|
if slices.Contains(denied, country) {
|
||||||
return true
|
return true
|
||||||
@@ -33,9 +19,3 @@ func (rq *request) countryDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasCountry reports whether addr can be placed in a country: private,
|
|
||||||
// loopback and link-local addresses cannot.
|
|
||||||
func hasCountry(addr netip.Addr) bool {
|
|
||||||
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,16 +56,21 @@ func TestCountryLists(t *testing.T) {
|
|||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
|
|
||||||
for i, sent := range []struct{ client, country string }{
|
// The AS number GeoJS gives unplaced, 64512, counts as unknown.
|
||||||
{fromDE, "DE"}, {fromKP, "KP"}, {unplaced, ""},
|
for i, sent := range []struct{ client, asn, asName, country string }{
|
||||||
|
{fromDE, asnDE, asNameDE, "DE"}, {fromKP, asnKP, asNameKP, "KP"},
|
||||||
|
{unplaced, "", "", ""},
|
||||||
} {
|
} {
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, sent.client)
|
req.Header.Set(forwardedFor, sent.client)
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.requestLines(t, i+1)[i]
|
||||||
if line.Country != sent.country {
|
if line.ASN != sent.asn || line.ASName != sent.asName ||
|
||||||
t.Errorf("log line has country %q, want %q", line.Country, sent.country)
|
line.Country != sent.country {
|
||||||
|
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
|
||||||
|
line.ASN, line.ASName, line.Country,
|
||||||
|
sent.asn, sent.asName, sent.country)
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(tc.refused, sent.client) {
|
if slices.Contains(tc.refused, sent.client) {
|
||||||
@@ -130,7 +135,7 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := []map[string]string{{"ip": r.URL.Query().Get("ip"), "country": "DE"}}
|
answer := []geojsAnswer{{IP: r.URL.Query().Get("ip"), CountryCode: "DE"}}
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(answer)
|
err := json.NewEncoder(w).Encode(answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,20 +179,15 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
clients []string // "" sends no X-Forwarded-For: the client is 127.0.0.1
|
|
||||||
}{
|
}{
|
||||||
{"no country list is set", nil, []string{fromKP, fromDE}},
|
{"no setting needs the lookup", nil},
|
||||||
{
|
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
||||||
"private, loopback and link-local addresses",
|
|
||||||
map[string]string{deniedCountries: "kp"},
|
|
||||||
[]string{"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9"},
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -198,7 +198,10 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
|||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
|
|
||||||
for i, sent := range tc.clients {
|
// "" sends no X-Forwarded-For: the client is 127.0.0.1.
|
||||||
|
for i, sent := range []string{
|
||||||
|
"10.0.0.5", "192.168.1.9", "fd00::5", "", "169.254.0.9", "fe80::9",
|
||||||
|
} {
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
if sent != "" {
|
if sent != "" {
|
||||||
req.Header.Set(forwardedFor, sent)
|
req.Header.Set(forwardedFor, sent)
|
||||||
@@ -209,15 +212,26 @@ func TestCountryNotLookedUpWithoutAListOrForAPrivateAddress(t *testing.T) {
|
|||||||
line := out.requestLines(t, i+1)[i]
|
line := out.requestLines(t, i+1)[i]
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
country, present := line.fields["country"]
|
for _, field := range []string{"asn", "as_name", "country"} {
|
||||||
if !present || country != "" {
|
value, present := line.fields[field]
|
||||||
t.Errorf("log line for %q has country %v, want an empty one",
|
if !present || value != "" {
|
||||||
line.ClientIP, country)
|
t.Errorf("log line for %q has %s %v, want an empty one",
|
||||||
|
line.ClientIP, field, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(asked()) != 0 {
|
// GeoJS is asked about up to 200 waiting clients at once, so once it
|
||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
// has been asked about fromDE, which comes last, it has been asked
|
||||||
|
// about every client before it that waited for an answer.
|
||||||
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
|
waitUntil(func() bool { return slices.Contains(asked(), fromDE) })
|
||||||
|
|
||||||
|
if got := asked(); !slices.Equal(got, []string{fromDE}) {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want %s alone", got, fromDE)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -264,18 +278,31 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// and no other address. It returns its URL, and what returns the
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// addresses it has been asked about.
|
// what returns the addresses it has been asked about.
|
||||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
places := map[string]string{fromDE: "DE", fromKP: "KP"}
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
|
release()
|
||||||
|
|
||||||
var asked struct {
|
return geojsURL, asked
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHeldGeoJS is startGeoJS for a stand-in that answers nothing until
|
||||||
|
// release is called. Each request to it waits until then.
|
||||||
|
func startHeldGeoJS(t *testing.T) (string, func() []string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
asked struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
addrs []string
|
addrs []string
|
||||||
}
|
}
|
||||||
|
released = make(chan struct{})
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
geojs := httptest.NewServer(http.HandlerFunc(
|
geojs := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -285,11 +312,11 @@ func startGeoJS(t *testing.T) (string, func() []string) {
|
|||||||
asked.addrs = append(asked.addrs, addrs...)
|
asked.addrs = append(asked.addrs, addrs...)
|
||||||
asked.mu.Unlock()
|
asked.mu.Unlock()
|
||||||
|
|
||||||
answers := make([]map[string]string, 0, len(addrs))
|
<-released
|
||||||
|
|
||||||
|
answers := make([]geojsAnswer, 0, len(addrs))
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
answers = append(answers, map[string]string{
|
answers = append(answers, answerAbout(addr))
|
||||||
"ip": addr, "country": places[addr],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(answers)
|
err := json.NewEncoder(w).Encode(answers)
|
||||||
@@ -299,10 +326,48 @@ func startGeoJS(t *testing.T) (string, func() []string) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(geojs.Close)
|
t.Cleanup(geojs.Close)
|
||||||
|
|
||||||
|
release := func() { once.Do(func() { close(released) }) }
|
||||||
|
// Run before geojs.Close, which waits for every request to be answered.
|
||||||
|
t.Cleanup(release)
|
||||||
|
|
||||||
return geojs.URL, func() []string {
|
return geojs.URL, func() []string {
|
||||||
asked.mu.Lock()
|
asked.mu.Lock()
|
||||||
defer asked.mu.Unlock()
|
defer asked.mu.Unlock()
|
||||||
|
|
||||||
return slices.Clone(asked.addrs)
|
return slices.Clone(asked.addrs)
|
||||||
|
}, release
|
||||||
|
}
|
||||||
|
|
||||||
|
// The AS numbers and names the stand-in for GeoJS gives fromDE and
|
||||||
|
// fromKP, as they are logged.
|
||||||
|
const (
|
||||||
|
asnDE = "AS64496"
|
||||||
|
asNameDE = "Example Net"
|
||||||
|
asnKP = "AS64511"
|
||||||
|
asNameKP = "Other Net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
|
// smallwebwaf reads.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
|
type geojsAnswer struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
ASN int `json:"asn"`
|
||||||
|
ASName string `json:"organization_name"`
|
||||||
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerAbout is what the stand-in for GeoJS answers about addr: for an
|
||||||
|
// address it cannot place, the AS number 64512 and the AS name Unknown
|
||||||
|
// with no country, as GeoJS does.
|
||||||
|
func answerAbout(addr string) geojsAnswer {
|
||||||
|
switch addr {
|
||||||
|
case fromDE:
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64496, ASName: asNameDE, CountryCode: "DE"}
|
||||||
|
case fromKP:
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64511, ASName: asNameKP, CountryCode: "KP"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return geojsAnswer{IP: addr, ASN: 64512, ASName: "Unknown"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
// Two let through, one over the limit, which bans the client, and one
|
// 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.
|
// refused under that ban, for which the client is not looked up. GeoJS
|
||||||
|
// answers about the client at its first request, and its later ones
|
||||||
|
// use that answer.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
@@ -35,8 +37,10 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
LastSeen: start.Add(2 * time.Second),
|
LastSeen: start.Add(2 * time.Second),
|
||||||
|
ASN: asnDE,
|
||||||
|
ASName: asNameDE,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
LookedUp: start.Add(time.Second),
|
LookedUp: start,
|
||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The headers in which the app is passed the client's AS number and
|
||||||
|
// country while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes every header
|
||||||
|
// name in this form, as it sends it and as it receives it, so X-Client-ASN
|
||||||
|
// arrives as X-Client-Asn, and Del removes a client's own whatever their
|
||||||
|
// case; header names are not case-sensitive.
|
||||||
|
const (
|
||||||
|
asnHeader = "X-Client-Asn"
|
||||||
|
countryHeader = "X-Client-Country"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookUp looks up the client's AS number and country, and notes them for
|
||||||
|
// the log line, unless SWWAF_LOOKUP_SOURCE is off or the client is on a
|
||||||
|
// private, loopback or link-local address, which no lookup can place.
|
||||||
|
// While a setting needs the answer, a new client's request waits for it.
|
||||||
|
// ctx is the request's own context.
|
||||||
|
func (rq *request) lookUp(ctx context.Context) {
|
||||||
|
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := rq.h.geojs.LookUp(ctx, clientGroup(rq.client))
|
||||||
|
rq.lookedUp = true
|
||||||
|
rq.line.ASN = answer.ASN
|
||||||
|
rq.line.ASName = answer.ASName
|
||||||
|
rq.line.Country = answer.Country
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLookup adds answer, GeoJS's answer about a client, to the client's
|
||||||
|
// history, and to the notes of the bans on its netblock that have no AS
|
||||||
|
// number, AS name or country yet.
|
||||||
|
func (h *handler) addLookup(answer lookup.Answer) {
|
||||||
|
h.limiter.AddLookup(answer.Client, answer.Answered,
|
||||||
|
answer.ASN, answer.ASName, answer.Country)
|
||||||
|
h.ledger.AddLookup(h.netblock(answer.Client.Addr()),
|
||||||
|
answer.ASN, answer.ASName, answer.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLookupHeaders sets the headers in which the app is passed the
|
||||||
|
// client's AS number and country, leaving out one that is unknown.
|
||||||
|
func setLookupHeaders(header http.Header, asn, country string) {
|
||||||
|
if asn != "" {
|
||||||
|
header.Set(asnHeader, asn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if country != "" {
|
||||||
|
header.Set(countryHeader, country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBePlaced reports whether a lookup can place addr: private, loopback
|
||||||
|
// and link-local addresses have no AS number or country.
|
||||||
|
func canBePlaced(addr netip.Addr) bool {
|
||||||
|
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// asnAndCountry is what a lookup gives a client: its AS number, AS name
|
||||||
|
// and country.
|
||||||
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
|
func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The stand-in for GeoJS answers only once released. A request that
|
||||||
|
// waited for it would wait an hour, and get no answer within
|
||||||
|
// waitLimit.
|
||||||
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupTimeout: "1h",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// fromDE's second request breaks the limit and bans it, and fromKP
|
||||||
|
// comes too. None waits for GeoJS.
|
||||||
|
for _, line := range []logLine{
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
|
} {
|
||||||
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
|
if got != (asnAndCountry{}) {
|
||||||
|
t.Errorf("log line has %+v before GeoJS answered, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once GeoJS answers, each answer reaches the client's history, and
|
||||||
|
// fromDE's reaches the notes of its ban.
|
||||||
|
release()
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
|
|
||||||
|
waitUntil(func() bool {
|
||||||
|
return historyOf(t, server, fromDE).ASN != "" &&
|
||||||
|
historyOf(t, server, fromKP).ASN != "" &&
|
||||||
|
server.Ledger.Bans(netblock)[0].Notes.ASN != ""
|
||||||
|
})
|
||||||
|
|
||||||
|
de := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||||
|
|
||||||
|
for addr, want := range map[string]asnAndCountry{
|
||||||
|
fromDE: de, fromKP: {asnKP, asNameKP, "KP"},
|
||||||
|
} {
|
||||||
|
h := historyOf(t, server, addr)
|
||||||
|
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want {
|
||||||
|
t.Errorf("%s's history has %+v, want %+v", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||||
|
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != de {
|
||||||
|
t.Errorf("the ban's notes have %+v, want %+v", got, de)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoJS was asked about each client once, fromKP after fromDE, whose
|
||||||
|
// request was under way when fromKP came.
|
||||||
|
if got := asked(); !slices.Equal(got, []string{fromDE, fromKP}) {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want %s and %s", got, fromDE, fromKP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
|
map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
// The answer is kept before the requests, so GeoJS is not asked, and
|
||||||
|
// gives no answer of its own.
|
||||||
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
|
server.GeoJS.Load([]lookup.Answer{{
|
||||||
|
Client: netblock, ASN: asnDE, ASName: asNameDE, Country: "DE",
|
||||||
|
Answered: clk.Now(), Used: clk.Now(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
want := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||||
|
|
||||||
|
for _, line := range []logLine{
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
|
} {
|
||||||
|
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||||
|
t.Errorf("log line has %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := historyOf(t, server, fromDE)
|
||||||
|
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want ||
|
||||||
|
!h.LookedUp.Equal(clk.Now()) {
|
||||||
|
t.Errorf("history has %+v, looked up at %s; want %+v, at %s",
|
||||||
|
got, h.LookedUp, want, clk.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||||
|
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != want {
|
||||||
|
t.Errorf("the ban's notes have %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 {
|
||||||
|
t.Fatalf("alerts waiting %+v, want the ban's alone", waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := waiting[0]
|
||||||
|
if got := (asnAndCountry{alert.ASN, alert.ASName, alert.Country}); got != want {
|
||||||
|
t.Errorf("the ban's alert has %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, asked := startGeoJS(t)
|
||||||
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{lookupSource: "off"})
|
||||||
|
|
||||||
|
// Even an answer kept from before is not used.
|
||||||
|
server.GeoJS.Load([]lookup.Answer{{
|
||||||
|
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
|
||||||
|
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
|
line := s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
|
if got != (asnAndCountry{}) {
|
||||||
|
t.Errorf("log line has %+v, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := historyOf(t, server, fromDE); h.ASN != "" || !h.LookedUp.IsZero() {
|
||||||
|
t.Errorf("history has %q, looked up at %s, want no lookup", h.ASN, h.LookedUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(asked()) != 0 {
|
||||||
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
got [][2][]string // each request's X-Client-ASN and X-Client-Country
|
||||||
|
)
|
||||||
|
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
got = append(got, [2][]string{
|
||||||
|
r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
addLookupHeaders: "true",
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
// Each client sends headers of its own. fromDE's first request waits
|
||||||
|
// for its answer, which the app is passed; unplaced has none to pass,
|
||||||
|
// and a client on a private address is not looked up.
|
||||||
|
for _, from := range []string{fromDE, unplaced, "10.0.0.8"} {
|
||||||
|
s.requestWithHeader(from, "/", clientsOwnLookupHeaders,
|
||||||
|
http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
want := [][2][]string{{{asnDE}, {"DE"}}, {nil, nil}, {nil, nil}}
|
||||||
|
if !slices.EqualFunc(got, want, func(a, b [2][]string) bool {
|
||||||
|
return slices.Equal(a[0], b[0]) && slices.Equal(a[1], b[1])
|
||||||
|
}) {
|
||||||
|
t.Errorf("the app was passed %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
asn, country []string
|
||||||
|
)
|
||||||
|
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
asn, country = r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country")
|
||||||
|
})
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
|
trustedProxies: trustLocalhost,
|
||||||
|
})
|
||||||
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
|
s.requestWithHeader(fromDE, "/", clientsOwnLookupHeaders,
|
||||||
|
http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if asn != nil || country != nil {
|
||||||
|
t.Errorf("the app was passed X-Client-ASN %v and X-Client-Country %v, want neither",
|
||||||
|
asn, country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The test runs in a synctest bubble, where the time package runs on a
|
||||||
|
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||||
|
// however slowly the test process runs. Nothing in it may wait on the
|
||||||
|
// network, which would keep that clock from moving on: the request is
|
||||||
|
// handed to the proxy's handler, and GeoJS is one that never answers.
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// Not the default second. The exclusive list needs the answer, and
|
||||||
|
// the app is never reached.
|
||||||
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
|
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||||||
|
time.Now, map[string]string{
|
||||||
|
lookupTimeout: timeout.String(),
|
||||||
|
allowedCountries: "DE",
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
|
||||||
|
http.NoBody)
|
||||||
|
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
||||||
|
began := time.Now()
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if waited := time.Since(began); waited != timeout {
|
||||||
|
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an answer, the client is in no country the list allows.
|
||||||
|
wantLine(t, out.requestLine(t), http.StatusForbidden,
|
||||||
|
requestlog.ActionCountryDenied)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unansweredGeoJSURL is where a GeoJS that never answers is asked: a
|
||||||
|
// request to it waits, without the network, until it is abandoned.
|
||||||
|
// TestMain registers it with Go's default transport, through which GeoJS
|
||||||
|
// is asked.
|
||||||
|
const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unansweredGeoJS is the GeoJS at unansweredGeoJSURL.
|
||||||
|
type unansweredGeoJS struct{}
|
||||||
|
|
||||||
|
// RoundTrip waits until req is abandoned.
|
||||||
|
func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientsOwnLookupHeaders are the X-Client-ASN and X-Client-Country a
|
||||||
|
// client sends of its own, each twice, in two cases.
|
||||||
|
const clientsOwnLookupHeaders = "X-Client-ASN: AS1\r\nx-client-asn: AS2\r\n" +
|
||||||
|
"X-CLIENT-COUNTRY: KP\r\nx-client-country: CN"
|
||||||
|
|
||||||
|
// waitUntil waits until done reports true, for at most waitLimit.
|
||||||
|
func waitUntil(done func() bool) {
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for !done() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,8 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
deniedCountries: "kp",
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, env)
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||||
|
|
||||||
// The answers are kept before the requests, so that none waits for
|
// The answers are kept before the requests, so that none waits for
|
||||||
// GeoJS.
|
// GeoJS.
|
||||||
@@ -361,6 +362,46 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
metricsToken: token,
|
||||||
|
metricsTopN: "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The answers are kept before the requests, so that GeoJS gives none
|
||||||
|
// of its own. Each client is in an AS of its own.
|
||||||
|
answer := func(addr, asn string) lookup.Answer {
|
||||||
|
return lookup.Answer{
|
||||||
|
Client: netip.MustParsePrefix(addr + "/32"), ASN: asn,
|
||||||
|
Answered: clk.Now(), Used: clk.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.GeoJS.Load([]lookup.Answer{
|
||||||
|
answer(fromDE, "AS64501"), answer(fromKP, "AS64502"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// With one AS number of its own, the other is counted as other. The
|
||||||
|
// metrics are asked for from a private address, which has no AS number.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
metrics := s.scrape("10.0.0.9")
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
||||||
|
wantNoSeries(t, metrics,
|
||||||
|
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -54,8 +54,8 @@ type Params struct {
|
|||||||
RequestLog io.Writer
|
RequestLog io.Writer
|
||||||
// ProcessLog receives the process's own messages.
|
// ProcessLog receives the process's own messages.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// GeoJSURL is where clients' countries are looked up, normally
|
// GeoJSURL is where clients' AS numbers and countries are looked up,
|
||||||
// lookup.URL. GeoJS is asked only while a country list is set.
|
// normally lookup.URL, unless SWWAF_LOOKUP_SOURCE is off.
|
||||||
GeoJSURL string
|
GeoJSURL string
|
||||||
// Now tells the time by which requests are counted for the rate
|
// 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,
|
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||||
@@ -109,16 +109,23 @@ func New(params Params) *Server {
|
|||||||
AttackBanDuration: params.Config.AttackBanDuration,
|
AttackBanDuration: params.Config.AttackBanDuration,
|
||||||
MaxBans: params.Config.MaxBans,
|
MaxBans: params.Config.MaxBans,
|
||||||
}),
|
}),
|
||||||
geojs: lookup.New(lookup.Params{
|
rules: params.Rules,
|
||||||
|
alerts: params.Alerts,
|
||||||
|
}
|
||||||
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
|
Timeout: params.Config.LookupTimeout,
|
||||||
|
// The country lists and the headers act on the answer before the
|
||||||
|
// request goes on.
|
||||||
|
Wait: len(params.Config.DeniedCountries) > 0 ||
|
||||||
|
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||||
|
params.Config.AddLookupHeaders,
|
||||||
|
Answered: h.addLookup,
|
||||||
Now: params.Now,
|
Now: params.Now,
|
||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
Alerts: params.Alerts,
|
Alerts: params.Alerts,
|
||||||
}),
|
})
|
||||||
rules: params.Rules,
|
|
||||||
alerts: params.Alerts,
|
|
||||||
}
|
|
||||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||||
m.AddRules(params.Rules)
|
m.AddRules(params.Rules)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ const (
|
|||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||||
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||||
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWWAF_BAN_RESPONSE"
|
||||||
@@ -203,8 +206,8 @@ func startProxy(t *testing.T, appURL string, env map[string]string) (string, *ou
|
|||||||
return startProxyWithGeoJS(t, appURL, "", env)
|
return startProxyWithGeoJS(t, appURL, "", env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithGeoJS is startProxy with clients' countries looked up at
|
// startProxyWithGeoJS is startProxy with clients' AS numbers and
|
||||||
// geojsURL.
|
// countries looked up at geojsURL.
|
||||||
func startProxyWithGeoJS(
|
func startProxyWithGeoJS(
|
||||||
t *testing.T, appURL, geojsURL string, env map[string]string,
|
t *testing.T, appURL, geojsURL string, env map[string]string,
|
||||||
) (string, *output) {
|
) (string, *output) {
|
||||||
@@ -232,17 +235,50 @@ func startProxyWithClock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||||
// the alerts the proxy raises as well, as the settings in env make it. No
|
// the alerts the proxy raises as well, as newProxy makes them.
|
||||||
// alert is sent from it: they wait in it, for the test to look at.
|
|
||||||
func startProxyWithAlerts(
|
func startProxyWithAlerts(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env)
|
||||||
|
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return listener.Addr().String(), out, server, alertQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProxy makes the server startProxyWithClock starts, without starting
|
||||||
|
// it, and returns it, what it writes, and the queue of the alerts the
|
||||||
|
// proxy raises, as the settings in env make it. No alert is sent from the
|
||||||
|
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
||||||
|
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
|
||||||
|
// is off unless env sets it.
|
||||||
|
func newProxy(
|
||||||
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
|
env map[string]string,
|
||||||
|
) (*proxy.Server, *output, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
}
|
}
|
||||||
|
if geojsURL == "" {
|
||||||
|
settings[lookupSource] = "off"
|
||||||
|
}
|
||||||
|
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
||||||
@@ -284,20 +320,7 @@ func startProxyWithAlerts(
|
|||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
})
|
})
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
return server, out, alertQueue
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = server.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = server.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
return listener.Addr().String(), out, server, alertQueue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newClient returns an HTTP client that sends requests as they are made,
|
// newClient returns an HTTP client that sends requests as they are made,
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
|
|||||||
+39
-13
@@ -48,6 +48,9 @@ type request struct {
|
|||||||
client netip.Addr
|
client netip.Addr
|
||||||
peer netip.Addr
|
peer netip.Addr
|
||||||
peerTrusted bool
|
peerTrusted bool
|
||||||
|
// lookedUp is true once the client's AS number and country have been
|
||||||
|
// looked up, whether or not an answer was there.
|
||||||
|
lookedUp bool
|
||||||
start time.Time
|
start time.Time
|
||||||
// checked is when the checks were done, and upstreamStart when the
|
// checked is when the checks were done, and upstreamStart when the
|
||||||
// request was handed to the app.
|
// request was handed to the app.
|
||||||
@@ -192,14 +195,15 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
// 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
|
// action of the first that refuses the request, or "" when none does. A
|
||||||
// client in SWWAF_ALLOW_NETS skips them. For any other client,
|
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
||||||
// SWWAF_DENY_NETS comes first, then a ban on its netblock, so that a
|
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
||||||
// client either refuses is not looked up, and then the country lists; a
|
// so that a client either refuses is not looked up, then the lookup of
|
||||||
// request any of them refuses is not counted for the rate limits. Then
|
// its AS number and country, and then the country lists; a request any of
|
||||||
// come the rate limits, unless the client is in
|
// them refuses is not counted for the rate limits. Then come the rate
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the request's path is exempt under
|
// limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request is counted,
|
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||||
// and last the rule files. ctx is the request's own context.
|
// every other request is counted, and last the rule files. ctx is the
|
||||||
|
// request's own context.
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if isInside(rq.client, cfg.AllowNets) {
|
if isInside(rq.client, cfg.AllowNets) {
|
||||||
@@ -216,7 +220,9 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return requestlog.ActionBanned
|
return requestlog.ActionBanned
|
||||||
}
|
}
|
||||||
|
|
||||||
if rq.countryDenied(ctx) {
|
rq.lookUp(ctx)
|
||||||
|
|
||||||
|
if rq.countryDenied() {
|
||||||
return requestlog.ActionCountryDenied
|
return requestlog.ActionCountryDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +293,9 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
|
|
||||||
// rewrite makes the request the app receives: the client's request,
|
// rewrite makes the request the app receives: the client's request,
|
||||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
||||||
// the request's id set.
|
// the request's id set, without any X-Client-ASN or X-Client-Country the
|
||||||
|
// client sent, whatever SWWAF_ADD_LOOKUP_HEADERS says, and, while it is
|
||||||
|
// set, with the client's AS number and country in them.
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
upstream := rq.h.config.UpstreamURL
|
upstream := rq.h.config.UpstreamURL
|
||||||
pr.Out.URL.Scheme = upstream.Scheme
|
pr.Out.URL.Scheme = upstream.Scheme
|
||||||
@@ -297,6 +305,12 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
|||||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
||||||
|
pr.Out.Header.Del(asnHeader)
|
||||||
|
pr.Out.Header.Del(countryHeader)
|
||||||
|
|
||||||
|
if rq.h.config.AddLookupHeaders {
|
||||||
|
setLookupHeaders(pr.Out.Header, rq.line.ASN, rq.line.Country)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
||||||
@@ -474,7 +488,10 @@ func timing(start, end time.Time) *float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addToHistory adds the request, which has ended, to its client's
|
// addToHistory adds the request, which has ended, to its client's
|
||||||
// history.
|
// history, and then, for a client that was looked up, the answer kept
|
||||||
|
// about it to that history and to the notes of the bans on its netblock:
|
||||||
|
// an answer that came during the request may have come before either was
|
||||||
|
// there, and one that comes later is added when it comes.
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
var requestBytes int64
|
var requestBytes int64
|
||||||
if rq.body != nil {
|
if rq.body != nil {
|
||||||
@@ -482,9 +499,9 @@ func (rq *request) addToHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
forwarded := !rq.upstreamStart.IsZero()
|
forwarded := !rq.upstreamStart.IsZero()
|
||||||
|
group := clientGroup(rq.client)
|
||||||
|
|
||||||
rq.h.limiter.AddToHistory(clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
rq.h.limiter.AddToHistory(group, rq.h.now(), ratelimit.Request{
|
||||||
Country: rq.line.Country,
|
|
||||||
Forwarded: forwarded,
|
Forwarded: forwarded,
|
||||||
Refused: !forwarded && rq.refused.Load() != nil,
|
Refused: !forwarded && rq.refused.Load() != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
@@ -492,6 +509,15 @@ func (rq *request) addToHistory() {
|
|||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if !rq.lookedUp {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, kept := rq.h.geojs.Kept(group)
|
||||||
|
if kept {
|
||||||
|
rq.h.addLookup(answer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientRequestDeadline is when the client must have sent its whole
|
// clientRequestDeadline is when the client must have sent its whole
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
start := midnight()
|
start := midnight()
|
||||||
|
|
||||||
for i, r := range []ratelimit.Request{
|
for i, r := range []ratelimit.Request{
|
||||||
{Country: "DE", Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
{Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
||||||
{Forwarded: true, Status: 101},
|
{Forwarded: true, Status: 101},
|
||||||
{Forwarded: true, Status: 304, RequestBytes: 5},
|
{Forwarded: true, Status: 304, RequestBytes: 5},
|
||||||
{Country: "FR", Refused: true, Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
{Refused: true, Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
||||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||||
// Closed without an answer: refused, and no response.
|
// Closed without an answer: refused, and no response.
|
||||||
{Refused: true, Status: 0},
|
{Refused: true, Status: 0},
|
||||||
@@ -33,8 +33,6 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
LastSeen: start.Add(6 * time.Minute),
|
LastSeen: start.Add(6 * time.Minute),
|
||||||
Country: "FR",
|
|
||||||
LookedUp: start.Add(3 * time.Minute),
|
|
||||||
Requests: 7,
|
Requests: 7,
|
||||||
Forwarded: 4,
|
Forwarded: 4,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
@@ -52,6 +50,43 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(ratelimit.Limits{})
|
||||||
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
|
start := midnight()
|
||||||
|
|
||||||
|
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
||||||
|
limiter.AddLookup(client, start, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
// A later answer replaces it, and one for a client the table does not
|
||||||
|
// hold adds no client.
|
||||||
|
limiter.AddLookup(client, start.Add(time.Hour), "AS64497", "Other Net", "FR")
|
||||||
|
limiter.AddLookup(other, start, "AS64496", "Example Net", "DE")
|
||||||
|
|
||||||
|
want := ratelimit.History{
|
||||||
|
FirstSeen: start,
|
||||||
|
LastSeen: start,
|
||||||
|
ASN: "AS64497",
|
||||||
|
ASName: "Other Net",
|
||||||
|
Country: "FR",
|
||||||
|
LookedUp: start.Add(time.Hour),
|
||||||
|
Requests: 1,
|
||||||
|
Forwarded: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := historyOf(t, limiter, client)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clients := limiter.Snapshot(); len(clients) != 1 {
|
||||||
|
t.Errorf("the table holds %+v, want %s alone", clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetKeepsTheHistory(t *testing.T) {
|
func TestResetKeepsTheHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ type Buckets struct {
|
|||||||
type History struct {
|
type History struct {
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
// Country is the client's country as it was last looked up, and
|
// ASN, ASName and Country are the client's AS number, AS name and
|
||||||
// LookedUp when that was; both are empty while it never was.
|
// country as last looked up, each empty when the lookup could not
|
||||||
|
// find it, and LookedUp is when GeoJS gave that answer; all are empty
|
||||||
|
// while the client never was looked up.
|
||||||
|
ASN string `json:"asn,omitempty"`
|
||||||
|
ASName string `json:"as_name,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||||
// Requests are all the client's requests: Forwarded those passed to
|
// Requests are all the client's requests: Forwarded those passed to
|
||||||
@@ -103,8 +107,6 @@ type Offences struct {
|
|||||||
|
|
||||||
// Request is what a client's history keeps of one of its requests.
|
// Request is what a client's history keeps of one of its requests.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// 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
|
// Forwarded is true for a request passed to the app, Refused for one
|
||||||
// refused before anything reached it, a 401 at smallwebwaf's own
|
// refused before anything reached it, a 401 at smallwebwaf's own
|
||||||
// endpoints included. Both are false for any other request smallwebwaf
|
// endpoints included. Both are false for any other request smallwebwaf
|
||||||
@@ -209,11 +211,6 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
|
|
||||||
h.LastSeen = now
|
h.LastSeen = now
|
||||||
|
|
||||||
if r.Country != "" {
|
|
||||||
h.Country = r.Country
|
|
||||||
h.LookedUp = now
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Requests++
|
h.Requests++
|
||||||
if r.Forwarded {
|
if r.Forwarded {
|
||||||
h.Forwarded++
|
h.Forwarded++
|
||||||
@@ -232,6 +229,24 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddLookup gives client's history its AS number, AS name and country, as
|
||||||
|
// GeoJS gave them at lookedUp, if the table of clients holds the client.
|
||||||
|
// It does not make the client the most recently seen.
|
||||||
|
func (l *Limiter) AddLookup(
|
||||||
|
client netip.Prefix, lookedUp time.Time, asn, asName, country string,
|
||||||
|
) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
c, held := l.clients.Peek(client)
|
||||||
|
if !held {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &c.History
|
||||||
|
h.ASN, h.ASName, h.Country, h.LookedUp = asn, asName, country, lookedUp
|
||||||
|
}
|
||||||
|
|
||||||
// Requests returns how many requests the clients inside netblock have
|
// Requests returns how many requests the clients inside netblock have
|
||||||
// sent, as their histories count them.
|
// sent, as their histories count them.
|
||||||
func (l *Limiter) Requests(netblock netip.Prefix) int64 {
|
func (l *Limiter) Requests(netblock netip.Prefix) int64 {
|
||||||
|
|||||||
@@ -80,11 +80,14 @@ type Line struct {
|
|||||||
// Request detail. RequestID is the X-Request-ID a trusted proxy sent,
|
// 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
|
// 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
|
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||||
// client is counted as.
|
// client is counted as. ASN, ASName and Country are the client's AS
|
||||||
|
// number, AS name and country, as looked up.
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
PeerIP string `json:"peer_ip"`
|
PeerIP string `json:"peer_ip"`
|
||||||
ForwardedFor string `json:"forwarded_for,omitempty"`
|
ForwardedFor string `json:"forwarded_for,omitempty"`
|
||||||
ClientGroup string `json:"client_group"`
|
ClientGroup string `json:"client_group"`
|
||||||
|
ASN string `json:"asn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
// ContentLength is the length of its body the request announced.
|
// ContentLength is the length of its body the request announced.
|
||||||
|
|||||||
@@ -104,12 +104,16 @@ func (o *output) text() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// run runs smallwebwaf with the settings in env until ctx is done, and
|
// run runs smallwebwaf with the settings in env until ctx is done, and
|
||||||
// returns its exit status.
|
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
||||||
|
// so that no test sends GeoJS its clients' addresses.
|
||||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||||
return smallwebwaf.Run(ctx, smallwebwaf.Params{
|
return smallwebwaf.Run(ctx, smallwebwaf.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
value, ok := env[name]
|
value, ok := env[name]
|
||||||
|
if !ok && name == "SWWAF_LOOKUP_SOURCE" {
|
||||||
|
return "off", true
|
||||||
|
}
|
||||||
|
|
||||||
return value, ok
|
return value, ok
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const (
|
|||||||
clientsJSON = "clients.json"
|
clientsJSON = "clients.json"
|
||||||
lookupsJSON = "lookups.json"
|
lookupsJSON = "lookups.json"
|
||||||
alertsJSON = "alerts.json"
|
alertsJSON = "alerts.json"
|
||||||
|
// The AS number and AS name the tests' clients are looked up in.
|
||||||
|
asn = "AS64496"
|
||||||
|
asName = "Example Net"
|
||||||
// What the process log says once Watch watches the directory, and as
|
// What the process log says once Watch watches the directory, and as
|
||||||
// it takes in an edit.
|
// it takes in an edit.
|
||||||
watching = "watching the state files for edits"
|
watching = "watching the state files for edits"
|
||||||
@@ -55,6 +58,8 @@ const permanentBansJSON = `{
|
|||||||
"cause": "admin",
|
"cause": "admin",
|
||||||
"reason": "scrapes every commit",
|
"reason": "scrapes every commit",
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"asn": "AS64496",
|
||||||
|
"as_name": "Example Net",
|
||||||
"country": "DE",
|
"country": "DE",
|
||||||
"limit": 1000,
|
"limit": 1000,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
@@ -1302,7 +1307,9 @@ func fill(params state.Params) {
|
|||||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
params.Ledger.Load([]bans.Ban{permanentBan()})
|
params.Ledger.Load([]bans.Ban{permanentBan()})
|
||||||
params.Ledger.BanForLimit(client, now, bans.Notes{Country: "DE", Limit: 1})
|
params.Ledger.BanForLimit(client, now, bans.Notes{
|
||||||
|
ASN: asn, ASName: asName, Country: "DE", Limit: 1,
|
||||||
|
})
|
||||||
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
|
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
|
||||||
bans.Notes{RuleID: "env-file", Target: "path"})
|
bans.Notes{RuleID: "env-file", Target: "path"})
|
||||||
|
|
||||||
@@ -1311,11 +1318,15 @@ func fill(params state.Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
params.Limiter.AddToHistory(client, now, ratelimit.Request{
|
params.Limiter.AddToHistory(client, now, ratelimit.Request{
|
||||||
Country: "DE", Forwarded: true, Status: 200, RequestBytes: 3, ResponseBytes: 5,
|
Forwarded: true, Status: 200, RequestBytes: 3, ResponseBytes: 5,
|
||||||
})
|
})
|
||||||
|
params.Limiter.AddLookup(client, now.Add(-time.Hour), asn, asName, "DE")
|
||||||
|
|
||||||
params.GeoJS.Load([]lookup.Answer{
|
params.GeoJS.Load([]lookup.Answer{
|
||||||
{Client: client, Country: "DE", Answered: now.Add(-time.Hour), Used: now},
|
{
|
||||||
|
Client: client, ASN: asn, ASName: asName, Country: "DE",
|
||||||
|
Answered: now.Add(-time.Hour), Used: now,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Client: netip.MustParsePrefix("192.0.2.1/32"),
|
Client: netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
Answered: now.Add(-time.Hour), Used: now.Add(-time.Minute),
|
Answered: now.Add(-time.Hour), Used: now.Add(-time.Minute),
|
||||||
@@ -1350,6 +1361,8 @@ func permanentBan() bans.Ban {
|
|||||||
Cause: bans.CauseAdmin,
|
Cause: bans.CauseAdmin,
|
||||||
Reason: "scrapes every commit",
|
Reason: "scrapes every commit",
|
||||||
Notes: bans.Notes{
|
Notes: bans.Notes{
|
||||||
|
ASN: asn,
|
||||||
|
ASName: asName,
|
||||||
Country: "DE",
|
Country: "DE",
|
||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
Window: "minute",
|
Window: "minute",
|
||||||
|
|||||||
+7
-5
@@ -7,9 +7,10 @@
|
|||||||
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
||||||
# `docker stop` stops the container without having to kill it, and 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
|
# a new container on the same volume still refuses the banned client. The
|
||||||
# containers, the volume and both images are removed however the script
|
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
||||||
# ends. Building the app needs network access, for nixpkgs' binary cache.
|
# to GeoJS. The containers, the volume and both images are removed however
|
||||||
# script/check does not run this.
|
# the script ends. Building the app needs network access, for nixpkgs'
|
||||||
|
# binary cache. script/check does not run this.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -63,12 +64,13 @@ logged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# start_container: run the app's container, with the state files on the
|
# 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
|
# volume, a rate limit of one request a minute and no client looked up,
|
||||||
# healthy.
|
# and wait until it is healthy.
|
||||||
start_container() {
|
start_container() {
|
||||||
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
|
||||||
--volume "$VOLUME:/var/lib/smallwebwaf" \
|
--volume "$VOLUME:/var/lib/smallwebwaf" \
|
||||||
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
|
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
|
||||||
|
--env SWWAF_LOOKUP_SOURCE=off \
|
||||||
"$APP_IMAGE" >/dev/null
|
"$APP_IMAGE" >/dev/null
|
||||||
wait_for "the health check did not pass" healthy
|
wait_for "the health check did not pass" healthy
|
||||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
address="$(docker port "$CONTAINER" 8080/tcp)"
|
||||||
|
|||||||
Reference in New Issue
Block a user