Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc50b04f2a | ||
|
|
f6435b1a73 |
No files matched your search
@@ -1,11 +1,12 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||||
run example-app
|
hooks build run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it.
|
# example-app checks the image with an app built on it; publish builds
|
||||||
|
# the image on the Docker host and pushes it to the registry.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -34,6 +35,9 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@script/publish
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ Not built, and not planned:
|
|||||||
|
|
||||||
### The image
|
### The image
|
||||||
|
|
||||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
||||||
the linter, then builds the image, tagged `smallwebwaf`.
|
from a clone. `make docker` runs the tests and the linter, then builds the
|
||||||
|
image, tagged `smallwebwaf`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||||
@@ -258,7 +259,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=cn`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
@@ -413,7 +414,7 @@ services:
|
|||||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=cn:25
|
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
@@ -505,10 +506,10 @@ of the request reaches the app.
|
|||||||
|
|
||||||
`discriminator` takes the checks:
|
`discriminator` takes the checks:
|
||||||
|
|
||||||
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
3. The tag lists.
|
||||||
4. The bans.
|
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
5. The lookup of the client's AS number and country.
|
5. The bans.
|
||||||
6. The tag lists.
|
6. The lookup of the client's AS number and country.
|
||||||
7. The country list.
|
7. The country list.
|
||||||
8. The reputation sources.
|
8. The reputation sources.
|
||||||
9. The tag refusals of `SWW_TAG_DENY`.
|
9. The tag refusals of `SWW_TAG_DENY`.
|
||||||
@@ -682,6 +683,15 @@ on the host.
|
|||||||
`make check`.
|
`make check`.
|
||||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||||
first. `make docker`.
|
first. `make docker`.
|
||||||
|
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
||||||
|
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
||||||
|
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
||||||
|
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
||||||
|
four tags:
|
||||||
|
- the UTC date as `YYYY-MM-DD`;
|
||||||
|
- the short commit hash;
|
||||||
|
- the branch name;
|
||||||
|
- `latest`.
|
||||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
|
|||||||
@@ -143,11 +143,13 @@ request log's `heuristic` field gives that name. `refusal_limit` and
|
|||||||
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
||||||
around its address, a /64 by default, since one abuser usually controls a
|
around its address, a /64 by default, since one abuser usually controls a
|
||||||
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
||||||
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 15, the byte
|
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
||||||
|
client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
|
||||||
|
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
|
||||||
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
||||||
(`deny_nets`); a client in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||||
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
6. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
|
||||||
country"). The request goes on at once. It waits for the first GeoJS answer,
|
country"). The request goes on at once. It waits for the first GeoJS answer,
|
||||||
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
|
||||||
- the country list;
|
- the country list;
|
||||||
@@ -158,8 +160,6 @@ request log's `heuristic` field gives that name. `refusal_limit` and
|
|||||||
|
|
||||||
A request that ends before the answer comes is logged without it.
|
A request that ends before the answer comes is logged without it.
|
||||||
|
|
||||||
6. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
|
||||||
client adds its tag.
|
|
||||||
7. The country list (`countries`; see "Country list").
|
7. The country list (`countries`; see "Country list").
|
||||||
8. The reputation sources (see "Reputation"), in this order:
|
8. The reputation sources (see "Reputation"), in this order:
|
||||||
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
|
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
|
||||||
@@ -401,7 +401,7 @@ Conventions:
|
|||||||
|
|
||||||
### Static lists
|
### Static lists
|
||||||
|
|
||||||
- `SWW_ALLOW_NETS` skips the checks step 3 names, for monitoring or the owner's
|
- `SWW_ALLOW_NETS` skips the checks step 4 names, for monitoring or the owner's
|
||||||
own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte
|
own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte
|
||||||
limits only; the error burst, attack detection and bans still apply.
|
limits only; the error burst, attack detection and bans still apply.
|
||||||
- Many people can share one address, as in an office, on a mobile carrier or
|
- Many people can share one address, as in an office, on a mobile carrier or
|
||||||
@@ -480,7 +480,7 @@ service that keeps its visitors' addresses on its own host.
|
|||||||
dropped from that table. The answer reaches the client's history and ban notes
|
dropped from that table. The answer reaches the client's history and ban notes
|
||||||
when it comes.
|
when it comes.
|
||||||
- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
|
- `SWW_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
|
||||||
first answer, when a setting needs it (see step 5). It also bounds each
|
first answer, when a setting needs it (see step 6). It also bounds each
|
||||||
request to GeoJS. A client whose answer has not come in time counts as unknown
|
request to GeoJS. A client whose answer has not come in time counts as unknown
|
||||||
until it comes, and its later requests do not wait. Its address is asked about
|
until it comes, and its later requests do not wait. Its address is asked about
|
||||||
again in the background.
|
again in the background.
|
||||||
@@ -534,8 +534,10 @@ the log line as `tags`, sorted, with empty lists left out (see
|
|||||||
|
|
||||||
Each tag and where it comes from:
|
Each tag and where it comes from:
|
||||||
|
|
||||||
- `asn=<number>` and `country=<code>`, in lower case, such as `asn=64496` and
|
- `asn=<number>` and `country=<code>`, such as `asn=64496` and `country=DE`,
|
||||||
`country=de`, from the lookup. A client the lookup cannot place has neither.
|
from the lookup. A client the lookup cannot place has neither. A request
|
||||||
|
refused by `deny_nets` or `active_ban` has neither, because those checks come
|
||||||
|
before the lookup.
|
||||||
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
|
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
|
||||||
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
|
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
|
||||||
fetched and kept like a blocklist. `discriminator` ships none of its own.
|
fetched and kept like a blocklist. `discriminator` ships none of its own.
|
||||||
@@ -546,8 +548,9 @@ Each tag and where it comes from:
|
|||||||
For the Core Rule Set a match is a score at or over
|
For the Core Rule Set a match is a score at or over
|
||||||
`SWW_WAF_ANOMALY_THRESHOLD`.
|
`SWW_WAF_ANOMALY_THRESHOLD`.
|
||||||
|
|
||||||
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. `SWW_TAG_DENY`
|
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
|
||||||
and `SWW_TAG_LIMIT_PERCENT` are read case-insensitively.
|
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
|
||||||
|
`SWW_TAG_LIMIT_PERCENT` accept tags in either case.
|
||||||
|
|
||||||
Two settings act on tags:
|
Two settings act on tags:
|
||||||
|
|
||||||
@@ -580,16 +583,16 @@ abuse comes from.
|
|||||||
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
|
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
|
||||||
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
abuse-source list can be shared by every `discriminator` in the fleet. It is
|
||||||
fetched and kept like a blocklist (see "Reputation"). Each entry counts as an
|
fetched and kept like a blocklist (see "Reputation"). Each entry counts like
|
||||||
`asn=` tag in `SWW_TAG_LIMIT_PERCENT` does. Where both give a percentage, the
|
an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. Where both give a percentage, the
|
||||||
lower applies.
|
lower applies.
|
||||||
- A client's limit percentage is the lowest of those that apply to it: its AS
|
- A client's limit percentage is the lowest of those that apply to it: its AS
|
||||||
number's, its country's, or a reputation hit's `limit:<percent>`. A client
|
number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
|
||||||
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
|
client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a
|
||||||
The percentage is 100 if none applies. Clients given a lower percentage are
|
country's. The percentage is 100 if none applies. Clients given a lower
|
||||||
banned after fewer requests, first for a while and then, for repeated abuse,
|
percentage are banned after fewer requests, first for a while and then, for
|
||||||
permanently. The log line of each counted request, and the notes of each ban,
|
repeated abuse, permanently. The log line of each counted request, and the
|
||||||
say which setting lowered the limit.
|
notes of each ban, say which setting lowered the limit.
|
||||||
- No budget is shared by a whole country or AS number. One abuser could use it
|
- No budget is shared by a whole country or AS number. One abuser could use it
|
||||||
up and lock out everyone else there: a denial of service nobody chose, and the
|
up and lock out everyone else there: a denial of service nobody chose, and the
|
||||||
one this tool exists to prevent. Refusing a whole country is left to the
|
one this tool exists to prevent. Refusing a whole country is left to the
|
||||||
@@ -1265,7 +1268,7 @@ seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
|
|||||||
and whatever its outside sources do.
|
and whatever its outside sources do.
|
||||||
|
|
||||||
GeoJS is the only outside source a question can wait on. A question waits only
|
GeoJS is the only outside source a question can wait on. A question waits only
|
||||||
when step 5 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
when step 6 says so, and for at most `SWW_LOOKUP_TIMEOUT`, by default 800
|
||||||
milliseconds. A client without an answer by then counts as unknown. No question
|
milliseconds. A client without an answer by then counts as unknown. No question
|
||||||
waits on these, which run in the background:
|
waits on these, which run in the background:
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -90,7 +90,7 @@ between the two (see "The image's command" below).
|
|||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
(see step 3 in [`SPEC.md`](../SPEC.md)).
|
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
@@ -164,7 +164,7 @@ Every request gets tags from what `discriminator` knows of its client. See
|
|||||||
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||||
or the CrowdSec decision list.
|
or the CrowdSec decision list.
|
||||||
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||||
`torexit,country=cn`. A tag listed twice, an `asn=` tag without a number, or a
|
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
||||||
`country=` tag without a country, is refused.
|
`country=` tag without a country, is refused.
|
||||||
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||||
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||||
@@ -173,6 +173,19 @@ Every request gets tags from what `discriminator` knows of its client. See
|
|||||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||||
|
that no request can get stops the start. A request can get only these:
|
||||||
|
|
||||||
|
- a name in `SWW_TAG_LISTS`;
|
||||||
|
- an `asn=` or `country=` tag;
|
||||||
|
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||||
|
masked as the `reputation` field shows it;
|
||||||
|
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||||
|
- `exploitsignature`, in `SWW_TAG_DENY` only.
|
||||||
|
|
||||||
|
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||||
|
the request first.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||||
@@ -184,8 +197,8 @@ Every request gets tags from what `discriminator` knows of its client. See
|
|||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. Each entry counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`
|
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||||
does. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
|
|||||||
+161
-51
@@ -101,8 +101,8 @@ type Config struct {
|
|||||||
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
|
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
|
||||||
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
|
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
|
||||||
// with a tag that percentage of every rate limit and byte limit, from 1
|
// with a tag that percentage of every rate limit and byte limit, from 1
|
||||||
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags in lower case, as requests
|
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
|
||||||
// carry them.
|
// lower case, a country code in capitals.
|
||||||
TagLists []TagList
|
TagLists []TagList
|
||||||
TagDeny []string
|
TagDeny []string
|
||||||
TagLimitPercent map[string]int64
|
TagLimitPercent map[string]int64
|
||||||
@@ -115,7 +115,7 @@ type Config struct {
|
|||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// UnknownLimitPercent is the percentage of every limit a client without
|
||||||
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count as asn= tags in TagLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL),
|
// count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL),
|
||||||
// "" while it is unset.
|
// "" while it is unset.
|
||||||
ASNBytesPercent map[string]int64
|
ASNBytesPercent map[string]int64
|
||||||
CountryBytesPercent map[string]int64
|
CountryBytesPercent map[string]int64
|
||||||
@@ -165,8 +165,8 @@ type Config struct {
|
|||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
|
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
|
||||||
// country lists.
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
|
||||||
BanResponse int
|
BanResponse int
|
||||||
// LimitBanDuration is the ban for a first broken rate limit
|
// LimitBanDuration is the ban for a first broken rate limit
|
||||||
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
@@ -398,18 +398,21 @@ var (
|
|||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php")
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
|
)
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
"is not the id of a Core Rule Set rule, a whole number such as 942100",
|
||||||
|
)
|
||||||
errSetupRuleID = errors.New(
|
errSetupRuleID = errors.New(
|
||||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
||||||
"up and of sww's own, which cannot be switched off")
|
"up and of sww's own, which cannot be switched off")
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
errNotLogRemoteURL = errors.New(
|
errNotLogRemoteURL = errors.New(
|
||||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||||
"and nothing more, such as syslog+tls://logs.example:6514")
|
"and nothing more, such as syslog+tls://logs.example:6514",
|
||||||
|
)
|
||||||
errNoCertificate = errors.New("holds no PEM certificate")
|
errNoCertificate = errors.New("holds no PEM certificate")
|
||||||
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
||||||
errNotAppName = errors.New(
|
errNotAppName = errors.New(
|
||||||
@@ -422,16 +425,21 @@ var (
|
|||||||
"is not a header name followed by : and the header's value, " +
|
"is not a header name followed by : and the header's value, " +
|
||||||
"such as Authorization:Bearer <token>")
|
"such as Authorization:Bearer <token>")
|
||||||
errControlCharacter = errors.New(
|
errControlCharacter = errors.New(
|
||||||
"holds a control character, such as the carriage return of a Windows line end")
|
"holds a control character, such as the carriage return of a Windows line end",
|
||||||
|
)
|
||||||
errNotAlertEvent = errors.New(
|
errNotAlertEvent = errors.New(
|
||||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||||
"source_failure or file_error")
|
"source_failure or file_error")
|
||||||
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
errNotNumberOrOff = errors.New(
|
||||||
|
"is not a whole number above zero, such as 60, or off",
|
||||||
|
)
|
||||||
errNotUTF8 = errors.New("is not valid UTF-8")
|
errNotUTF8 = errors.New("is not valid UTF-8")
|
||||||
errNotASN = errors.New("is not an AS number such as AS64496")
|
errNotASN = errors.New("is not an AS number such as AS64496")
|
||||||
errNotPercentItem = errors.New(
|
errNotPercentItem = errors.New(
|
||||||
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
||||||
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
|
errNotPercent = errors.New(
|
||||||
|
"is not a percentage, a whole number from 0 to 100",
|
||||||
|
)
|
||||||
errListedTwice = errors.New("is listed twice")
|
errListedTwice = errors.New("is listed twice")
|
||||||
errNotListURL = errors.New(
|
errNotListURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
@@ -440,11 +448,17 @@ var (
|
|||||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||||
errNotAction = errors.New(
|
errNotAction = errors.New(
|
||||||
"is not deny, limit:<percent> such as limit:25, or log")
|
"is not deny, limit:<percent> such as limit:25, or log")
|
||||||
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
errNotZone = errors.New(
|
||||||
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
|
"is not a DNS zone such as dnsbl.dronebl.org",
|
||||||
"names IPv6 clients are asked about by")
|
)
|
||||||
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
errZoneTooLong = errors.New(
|
||||||
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
"is longer than 189 characters, too long for the " +
|
||||||
|
"names IPv6 clients are asked about by",
|
||||||
|
)
|
||||||
|
errNotResolver = errors.New(
|
||||||
|
"is not an IP address with an optional port, " +
|
||||||
|
"such as 192.0.2.53 or [2001:db8::53]:5353",
|
||||||
|
)
|
||||||
errNotLAPIURL = errors.New(
|
errNotLAPIURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080")
|
"such as http://172.17.0.1:8080")
|
||||||
@@ -464,6 +478,12 @@ var (
|
|||||||
errExploitTag = errors.New(
|
errExploitTag = errors.New(
|
||||||
"cannot lower limits: the rate limits count a request before its " +
|
"cannot lower limits: the rate limits count a request before its " +
|
||||||
"rules are matched")
|
"rules are matched")
|
||||||
|
errCrowdSecTag = errors.New(
|
||||||
|
"cannot be used: a CrowdSec decision refuses the request first")
|
||||||
|
errNoRequestGetsTag = errors.New(
|
||||||
|
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
|
||||||
|
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
|
||||||
|
"or abuseipdb with SWW_ABUSEIPDB_KEY set")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -473,7 +493,9 @@ var (
|
|||||||
// setting that is set but invalid is an error that names it.
|
// setting that is set but invalid is an error that names it.
|
||||||
//
|
//
|
||||||
//nolint:funlen // one line for each setting, a list that grows with them
|
//nolint:funlen // one line for each setting, a list that grows with them
|
||||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
func FromEnvironment(
|
||||||
|
lookupEnv func(string) (string, bool),
|
||||||
|
) (*Config, error) {
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
||||||
@@ -581,6 +603,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
env.checkASNLimitPercentURL(cfg)
|
env.checkASNLimitPercentURL(cfg)
|
||||||
env.checkCrowdSec(cfg)
|
env.checkCrowdSec(cfg)
|
||||||
env.checkTagLists(cfg)
|
env.checkTagLists(cfg)
|
||||||
|
env.checkTags(cfg)
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return nil, env.err
|
return nil, env.err
|
||||||
@@ -672,7 +695,10 @@ func (e *environment) lookup(name string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if set {
|
if set {
|
||||||
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
|
e.check(
|
||||||
|
name,
|
||||||
|
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
|
||||||
|
)
|
||||||
|
|
||||||
return value, set
|
return value, set
|
||||||
}
|
}
|
||||||
@@ -955,7 +981,10 @@ func (e *environment) zones(name string) []string {
|
|||||||
logged[i] = MaskZoneKey(zone)
|
logged[i] = MaskZoneKey(zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
e.settings = append(
|
||||||
|
e.settings,
|
||||||
|
slog.String(name, strings.Join(logged, ",")),
|
||||||
|
)
|
||||||
|
|
||||||
return zones
|
return zones
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1053,7 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
|
|||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.TagDeny, countryTagPrefix+strings.ToLower(country)) {
|
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
||||||
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1127,51 @@ func (e *environment) checkTagLists(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would
|
||||||
|
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
||||||
|
// any tag these settings cannot give a request.
|
||||||
|
func (e *environment) checkTags(cfg *Config) {
|
||||||
|
for _, tag := range cfg.TagDeny {
|
||||||
|
e.check("SWW_TAG_DENY", checkTag(cfg, tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag := range cfg.TagLimitPercent {
|
||||||
|
e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTag returns an error when tag would never apply, as checkTags says.
|
||||||
|
func checkTag(cfg *Config, tag string) error {
|
||||||
|
if tag == CrowdSecTag {
|
||||||
|
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !requestCanGetTag(cfg, tag) {
|
||||||
|
return fmt.Errorf("%q %w", MaskZoneKey(tag), errNoRequestGetsTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
|
||||||
|
func requestCanGetTag(cfg *Config, tag string) bool {
|
||||||
|
isListName := func(list TagList) bool { return list.Name == tag }
|
||||||
|
if isLookupTag(tag) || tag == ExploitSignatureTag ||
|
||||||
|
slices.ContainsFunc(cfg.TagLists, isListName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag == AbuseIPDBTag {
|
||||||
|
return cfg.AbuseIPDBKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
|
||||||
|
|
||||||
|
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
|
||||||
|
return strings.ToLower(MaskZoneKey(each)) == zone
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
|
||||||
// names an asn= or country= tag, which only the lookup can give a request.
|
// names an asn= or country= tag, which only the lookup can give a request.
|
||||||
func (c *Config) TagsNeedLookup() bool {
|
func (c *Config) TagsNeedLookup() bool {
|
||||||
@@ -1118,7 +1192,7 @@ func tagLimitNeedsLookups(cfg *Config) bool {
|
|||||||
|
|
||||||
// isLookupTag reports whether tag is an asn= or country= tag.
|
// isLookupTag reports whether tag is an asn= or country= tag.
|
||||||
func isLookupTag(tag string) bool {
|
func isLookupTag(tag string) bool {
|
||||||
return strings.HasPrefix(tag, asnTagPrefix) || strings.HasPrefix(tag, countryTagPrefix)
|
return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerNames reads a setting that is a list of header names, and
|
// headerNames reads a setting that is a list of header names, and
|
||||||
@@ -1132,7 +1206,9 @@ func (e *environment) headerNames(name, defaultValue string) []string {
|
|||||||
|
|
||||||
// durationNotOff reads a setting that is a duration and, unlike a
|
// durationNotOff reads a setting that is a duration and, unlike a
|
||||||
// timeout, cannot be off.
|
// timeout, cannot be off.
|
||||||
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
func (e *environment) durationNotOff(
|
||||||
|
name, defaultValue string,
|
||||||
|
) time.Duration {
|
||||||
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
|
|
||||||
@@ -1331,7 +1407,10 @@ func (e *environment) instanceName() string {
|
|||||||
// appName reads the setting that is the APP-NAME of the records the log
|
// appName reads the setting that is the APP-NAME of the records the log
|
||||||
// lines are sent in, by default the instance name. Its value is checked
|
// lines are sent in, by default the instance name. Its value is checked
|
||||||
// when it is set, and, while lines are sent, when it is the instance name.
|
// when it is set, and, while lines are sent, when it is the instance name.
|
||||||
func (e *environment) appName(name, instanceName string, sending bool) string {
|
func (e *environment) appName(
|
||||||
|
name, instanceName string,
|
||||||
|
sending bool,
|
||||||
|
) string {
|
||||||
value, set := e.lookup(name)
|
value, set := e.lookup(name)
|
||||||
if !set {
|
if !set {
|
||||||
value = instanceName
|
value = instanceName
|
||||||
@@ -1354,7 +1433,10 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
|||||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||||
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
||||||
// the instance name in a header, which cannot hold one.
|
// the instance name in a header, which cannot hold one.
|
||||||
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
func (e *environment) checkInstanceNameForNtfy(
|
||||||
|
instanceName string,
|
||||||
|
ntfySet bool,
|
||||||
|
) {
|
||||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||||
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
||||||
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
||||||
@@ -1678,7 +1760,10 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
}
|
}
|
||||||
|
|
||||||
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
|
named = append(
|
||||||
|
named,
|
||||||
|
anomaly.NamedNetblock{Name: name, Netblock: netblock},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return named, nil
|
return named, nil
|
||||||
@@ -1826,16 +1911,19 @@ func ParseASN(value string) (string, error) {
|
|||||||
return "AS" + strconv.FormatUint(number, 10), nil
|
return "AS" + strconv.FormatUint(number, 10), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// The prefixes of the tags the lookup gives a request.
|
// The tags a request can get that the settings can name, as discriminator
|
||||||
|
// writes them. The prefixes are followed by a value.
|
||||||
const (
|
const (
|
||||||
asnTagPrefix = "asn="
|
ASNTagPrefix = "asn="
|
||||||
countryTagPrefix = "country="
|
CountryTagPrefix = "country="
|
||||||
|
DNSBLTagPrefix = "dnsbl="
|
||||||
|
AbuseIPDBTag = "abuseipdb"
|
||||||
|
CrowdSecTag = "crowdsec"
|
||||||
|
// ExploitSignatureTag is the tag of a request a rule file or the Core
|
||||||
|
// Rule Set matched.
|
||||||
|
ExploitSignatureTag = "exploitsignature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExploitSignatureTag is the tag of a request a rule file or the Core Rule
|
|
||||||
// Set matched.
|
|
||||||
const ExploitSignatureTag = "exploitsignature"
|
|
||||||
|
|
||||||
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
|
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
|
||||||
// requests of the clients it holds, and the URL it is fetched from.
|
// requests of the clients it holds, and the URL it is fetched from.
|
||||||
type TagList struct {
|
type TagList struct {
|
||||||
@@ -1901,27 +1989,30 @@ func parseTags(value string) ([]string, error) {
|
|||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTag reads a tag in either case, and returns it as a request carries
|
// parseTag reads a tag in either case, with spaces around it, and returns it
|
||||||
// it: in lower case, an asn= tag's number without leading zeros. An asn= or
|
// as a request carries it: in lower case, but for a country code, which is in
|
||||||
// country= tag must name a number or a country, so that a mistyped one
|
// capitals, and an asn= tag's number without leading zeros. An asn= or
|
||||||
// stops the start instead of never matching.
|
// country= tag must name a number or a country, so that a mistyped one stops
|
||||||
|
// the start instead of never matching.
|
||||||
func parseTag(value string) (string, error) {
|
func parseTag(value string) (string, error) {
|
||||||
tag := strings.ToLower(value)
|
tag := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
|
||||||
if digits, isASN := strings.CutPrefix(tag, asnTagPrefix); isASN {
|
if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN {
|
||||||
number, err := strconv.ParseUint(digits, 10, 32)
|
number, err := strconv.ParseUint(digits, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%q %w", value, errNotASNTag)
|
return "", fmt.Errorf("%q %w", value, errNotASNTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
return asnTagPrefix + strconv.FormatUint(number, 10), nil
|
return ASNTagPrefix + strconv.FormatUint(number, 10), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if code, isCountry := strings.CutPrefix(tag, countryTagPrefix); isCountry {
|
if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry {
|
||||||
_, err := parseCountry(code)
|
country, err := parseCountry(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CountryTagPrefix + country, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return tag, nil
|
return tag, nil
|
||||||
@@ -2060,8 +2151,11 @@ func parseHeaderNames(value string) ([]string, error) {
|
|||||||
header := strings.ToLower(item)
|
header := strings.ToLower(item)
|
||||||
switch header {
|
switch header {
|
||||||
case "host":
|
case "host":
|
||||||
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
return nil, fmt.Errorf(
|
||||||
item, errHeaderTakenOut)
|
"%q %w; the request's host is the field host",
|
||||||
|
item,
|
||||||
|
errHeaderTakenOut,
|
||||||
|
)
|
||||||
case "transfer-encoding":
|
case "transfer-encoding":
|
||||||
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
||||||
}
|
}
|
||||||
@@ -2098,9 +2192,11 @@ func parseUpstreamURL(value string) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
||||||
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
|
upstream.Hostname() != "" && upstream.User == nil &&
|
||||||
|
upstream.Opaque == "" &&
|
||||||
(upstream.Path == "" || upstream.Path == "/") &&
|
(upstream.Path == "" || upstream.Path == "/") &&
|
||||||
upstream.RawQuery == "" && upstream.Fragment == ""
|
upstream.RawQuery == "" &&
|
||||||
|
upstream.Fragment == ""
|
||||||
if !onlySchemeAndHost {
|
if !onlySchemeAndHost {
|
||||||
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
||||||
}
|
}
|
||||||
@@ -2124,7 +2220,11 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
|
schemes := []string{
|
||||||
|
remotelog.SchemeUDP,
|
||||||
|
remotelog.SchemeTCP,
|
||||||
|
remotelog.SchemeTLS,
|
||||||
|
}
|
||||||
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
||||||
|
|
||||||
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
||||||
@@ -2187,7 +2287,9 @@ func isHTTPURL(u *url.URL) bool {
|
|||||||
|
|
||||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
||||||
(u.Port() == "" || (err == nil && port != 0)) &&
|
(u.Port() == "" || (err == nil && port != 0)) &&
|
||||||
u.User == nil && u.Opaque == "" && u.Fragment == ""
|
u.User == nil &&
|
||||||
|
u.Opaque == "" &&
|
||||||
|
u.Fragment == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isListURL reports whether value is a URL a list can be fetched from, as
|
// isListURL reports whether value is a URL a list can be fetched from, as
|
||||||
@@ -2254,9 +2356,12 @@ func parseZones(value string) ([]string, error) {
|
|||||||
|
|
||||||
for i, zone := range zones {
|
for i, zone := range zones {
|
||||||
shown := MaskZoneKey(zone)
|
shown := MaskZoneKey(zone)
|
||||||
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
listedBefore := slices.ContainsFunc(
|
||||||
|
zones[:i],
|
||||||
|
func(earlier string) bool {
|
||||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(zone) > maxZoneLength:
|
case len(zone) > maxZoneLength:
|
||||||
@@ -2343,8 +2448,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
|
|||||||
name, headerValue, found := strings.Cut(item, ":")
|
name, headerValue, found := strings.Cut(item, ":")
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
if !found || !IsHeaderName(name) ||
|
||||||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"item %d %w",
|
||||||
|
i+1,
|
||||||
|
errNotWebhookHeader,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.Add(name, strings.TrimSpace(headerValue))
|
headers.Add(name, strings.TrimSpace(headerValue))
|
||||||
|
|||||||
@@ -1180,7 +1180,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
addLookupHeaders: enabled,
|
addLookupHeaders: enabled,
|
||||||
tagDeny: "country=kp",
|
tagDeny: "country=KP",
|
||||||
tagLimitPercent: "asn=64496:50",
|
tagLimitPercent: "asn=64496:50",
|
||||||
asnLimitPercentURL: asnURL,
|
asnLimitPercentURL: asnURL,
|
||||||
asnBytesPercent: "AS64496:50",
|
asnBytesPercent: "AS64496:50",
|
||||||
@@ -1210,6 +1210,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
// any other anomaly threshold, or a blocklist.
|
// any other anomaly threshold, or a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
||||||
|
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
||||||
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
||||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||||
blocklistURLs: dropURL,
|
blocklistURLs: dropURL,
|
||||||
@@ -1356,8 +1357,11 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// torExitTag is a tag these tests name.
|
// torExitTag and zenTag are tags these tests name.
|
||||||
const torExitTag = "torexit"
|
const (
|
||||||
|
torExitTag = "torexit"
|
||||||
|
zenTag = "dnsbl=zen.example"
|
||||||
|
)
|
||||||
|
|
||||||
func TestTagSettingsAsSet(t *testing.T) {
|
func TestTagSettingsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -1369,8 +1373,9 @@ func TestTagSettingsAsSet(t *testing.T) {
|
|||||||
cfg := fromEnvironment(t, environment{
|
cfg := fromEnvironment(t, environment{
|
||||||
tagLists: "TorExit=" + torURL +
|
tagLists: "TorExit=" + torURL +
|
||||||
", vpn=mullvad=https://example.com/mullvad.txt",
|
", vpn=mullvad=https://example.com/mullvad.txt",
|
||||||
tagDeny: "TorExit, Country=KP, asn=014061",
|
dnsblZones: "Zen.example",
|
||||||
tagLimitPercent: "vpn:50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
tagDeny: "TorExit, Country=kp, asn=014061",
|
||||||
|
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantLists := []config.TagList{
|
wantLists := []config.TagList{
|
||||||
@@ -1381,19 +1386,71 @@ func TestTagSettingsAsSet(t *testing.T) {
|
|||||||
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDeny := []string{torExitTag, "country=kp", "asn=14061"}
|
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
|
||||||
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
||||||
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPercents := map[string]int64{
|
wantPercents := map[string]int64{
|
||||||
"vpn": 50, "asn=14061": 25, "country=cn": 1, "dnsbl=zen.example": 99,
|
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
|
||||||
}
|
}
|
||||||
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
||||||
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
list = "torexit=https://example.com/tor.txt"
|
||||||
|
key = "abc123"
|
||||||
|
zone = key + ".xbl.dq.spamhaus.net"
|
||||||
|
masked = "********.xbl.dq.spamhaus.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env environment
|
||||||
|
want string // "" is accepted
|
||||||
|
}{
|
||||||
|
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
|
||||||
|
{
|
||||||
|
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
|
||||||
|
`"torexti"`,
|
||||||
|
},
|
||||||
|
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
|
||||||
|
{
|
||||||
|
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
|
||||||
|
masked,
|
||||||
|
},
|
||||||
|
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
|
||||||
|
{
|
||||||
|
"abuseipdb with a key",
|
||||||
|
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
|
||||||
|
},
|
||||||
|
{"crowdsec, though a list has its name", environment{
|
||||||
|
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
|
||||||
|
}, `"crowdsec" cannot be used`},
|
||||||
|
{"exploitsignature", environment{tagDeny: "exploitsignature"}, ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case tc.want == "" && err != nil:
|
||||||
|
t.Errorf("error %v, want none", err)
|
||||||
|
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
|
||||||
|
t.Errorf("error %v, want one holding %s", err, tc.want)
|
||||||
|
case err != nil && strings.Contains(err.Error(), key):
|
||||||
|
t.Errorf("error %v shows the zone's key", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2085,6 +2142,11 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
||||||
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
||||||
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
|
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
|
||||||
|
{tagDeny, "torexti"}, {tagDeny, zenTag},
|
||||||
|
{tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
|
||||||
|
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
|
||||||
|
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
|
||||||
|
{tagLimitPercent, "vpn :50"},
|
||||||
{tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, {tagLimitPercent, "vpn:"},
|
{tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, {tagLimitPercent, "vpn:"},
|
||||||
{tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
{tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
||||||
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
@@ -40,7 +41,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rq.abuseIPDBHit = true
|
rq.abuseIPDBHit = true
|
||||||
rq.addTag("abuseipdb")
|
rq.addTag(config.AbuseIPDBTag)
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ const (
|
|||||||
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||||
asnTagDEHalf = "asn=64496:50"
|
asnTagDEHalf = "asn=64496:50"
|
||||||
asnTagDEQuarter = "asn=64496:25"
|
asnTagDEQuarter = "asn=64496:25"
|
||||||
countryTagDEHalf = "country=de:50"
|
countryTagDEHalf = "country=DE:50"
|
||||||
countryTagDEQuarter = "country=de:25"
|
countryTagDEQuarter = "country=DE:25"
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -101,7 +101,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{tagLimitPercent, "Country=DE:50", fromDE},
|
{tagLimitPercent, "Country=de:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -328,7 +328,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
map[string]string{
|
||||||
|
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||||
|
})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,7 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.addTag("crowdsec")
|
rq.addTag(config.CrowdSecTag)
|
||||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||||
"listed by the CrowdSec decision list")
|
"listed by the CrowdSec decision list")
|
||||||
rq.banForCrowdSec(now, decision)
|
rq.banForCrowdSec(now, decision)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package discriminator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
for _, zone := range listedBy {
|
for _, zone := range listedBy {
|
||||||
rq.addTag("dnsbl=" + zone)
|
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const (
|
|||||||
// country are looked up, so that a client they refuse is not looked up.
|
// country are looked up, so that a client they refuse is not looked up.
|
||||||
func beforeLookup() []heuristic {
|
func beforeLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
|
tagLists{},
|
||||||
denyNets{},
|
denyNets{},
|
||||||
activeBan{},
|
activeBan{},
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,6 @@ func beforeLookup() []heuristic {
|
|||||||
// last, on the tag rule_files and core_rule_set can add.
|
// last, on the tag rule_files and core_rule_set can add.
|
||||||
func afterLookup() []heuristic {
|
func afterLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
tagLists{},
|
|
||||||
countries{},
|
countries{},
|
||||||
blocklists{},
|
blocklists{},
|
||||||
crowdSec{},
|
crowdSec{},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,17 +67,17 @@ func asnTag(asn string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return "asn=" + strings.TrimPrefix(asn, "AS")
|
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
|
||||||
}
|
}
|
||||||
|
|
||||||
// countryTag returns the tag of country, a code as the lookup gives it, such
|
// countryTag returns the tag of country, a code as the lookup gives it, such
|
||||||
// as country=de for DE, or "" for no country.
|
// as country=DE for DE, or "" for no country.
|
||||||
func countryTag(country string) string {
|
func countryTag(country string) string {
|
||||||
if country == "" {
|
if country == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return "country=" + strings.ToLower(country)
|
return config.CountryTagPrefix + country
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLookup adds answer, an answer about a client from the lookup
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const (
|
|||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
tagDeny = "SWW_TAG_DENY"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
||||||
denyKP = "country=kp"
|
denyKP = "country=KP"
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWW_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// A ban, SWW_DENY_NETS and the country lists still refuse a path
|
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
||||||
// under a prefix.
|
// prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
s.request(fromKP, "/assets/app.js",
|
s.request(fromKP, "/assets/app.js",
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
|||||||
func(server *discriminator.Server) {
|
func(server *discriminator.Server) {
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
},
|
},
|
||||||
[]string{"asn=64496", "country=de", "dnsbl=" + dnsblZone},
|
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
abuseipdb,
|
abuseipdb,
|
||||||
@@ -507,7 +507,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
|||||||
func(server *discriminator.Server) {
|
func(server *discriminator.Server) {
|
||||||
loadScores(server, map[string]int64{fromDE: 80})
|
loadScores(server, map[string]int64{fromDE: 80})
|
||||||
},
|
},
|
||||||
[]string{abuseipdb, "asn=64496", "country=de"},
|
[]string{abuseipdb, "asn=64496", "country=DE"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -138,11 +138,9 @@ func (h *handler) startRequest(
|
|||||||
return rq
|
return rq
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTag adds tag, in lower case, to the request's tags, which stay sorted and
|
// addTag adds tag to the request's tags, which stay sorted and hold each tag
|
||||||
// hold each tag once.
|
// once.
|
||||||
func (rq *request) addTag(tag string) {
|
func (rq *request) addTag(tag string) {
|
||||||
tag = strings.ToLower(tag)
|
|
||||||
|
|
||||||
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
||||||
if !found {
|
if !found {
|
||||||
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWW_DENY_NETS, the country lists nor the limit of one
|
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
|
||||||
// request a minute refuses the client, and its country is not looked
|
// minute refuses the client, and its country is not looked up.
|
||||||
// up.
|
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
@@ -147,8 +146,8 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// listedAddr's requests are neither refused nor counted, so the first
|
// listedAddr's requests are neither refused nor counted, so the first
|
||||||
// request from unlistedAddr is within the limit of one a minute. The
|
// request from unlistedAddr is within the limit of one a minute.
|
||||||
// country lists still refuse an exempt client.
|
// SWW_TAG_DENY still refuses an exempt client.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=KP"})
|
// The setting takes a country code in either case; the request's tag is
|
||||||
|
// in capitals.
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "country=kp"}`)
|
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
||||||
wantTags(t, line, "asn=64511", "country=kp")
|
wantTags(t, line, "asn=64511", "country=KP")
|
||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
@@ -73,3 +75,31 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
|||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
"25 from "+tagLimitPercent)
|
"25 from "+tagLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: twoUploads,
|
||||||
|
tagLists: "vpn=" + vpnList,
|
||||||
|
tagLimitPercent: "vpn:50",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
|
||||||
|
|
||||||
|
// The upload's 100 bytes are over half of 199, 99.
|
||||||
|
line := s.uploadFrom(fromDE)
|
||||||
|
if line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
"50 from "+tagLimitPercent)
|
||||||
|
|
||||||
|
// fromKP, which the list does not hold, has the whole limit.
|
||||||
|
line = s.uploadFrom(fromKP)
|
||||||
|
if line.LimitHit != "" {
|
||||||
|
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
||||||
|
}
|
||||||
@@ -43,9 +43,9 @@ func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
"asn=64496", "country=de", "torexit", "vpn=mullvad")
|
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
||||||
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
"asn=64511", "country=kp", "torexit")
|
"asn=64511", "country=KP", "torexit")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
||||||
@@ -69,5 +69,32 @@ func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
|||||||
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
||||||
|
|
||||||
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
"asn=64511", "country=kp", "vpn")
|
"asn=64511", "country=KP", "vpn")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
if line.Heuristic != "deny_nets" {
|
||||||
|
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTags(t, line, "vpn")
|
||||||
|
|
||||||
|
// The second request breaks the limit of one a minute, which bans the client.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.Heuristic != "active_ban" {
|
||||||
|
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTags(t, line, "vpn")
|
||||||
}
|
}
|
||||||
+14
-4
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
f.params.ProcessLog.Error(
|
||||||
"error", err.Error())
|
"cannot watch the lookup database for replacements",
|
||||||
|
"error",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
Detail: map[string]any{
|
||||||
|
"file": f.params.Path,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
f.params.ProcessLog.Info(
|
||||||
|
"read the lookup database",
|
||||||
|
"file",
|
||||||
|
f.params.Path,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
// country, the refusals by the country lists as well.
|
// country, the refusals by the country list as well.
|
||||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
if b.refused != nil {
|
if b.refused != nil {
|
||||||
|
|||||||
@@ -115,11 +115,11 @@ type list struct {
|
|||||||
failures int
|
failures int
|
||||||
}
|
}
|
||||||
|
|
||||||
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
||||||
// it names, with the lengths among them, for the file of AS:percent lines,
|
// netblocks it names, with the lengths among them, for the file of AS:percent
|
||||||
// the percentage it gives each AS number, and for the CrowdSec decision
|
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
||||||
// list, the decision on each netblock that ends last, with the lengths
|
// list, the decision on each netblock that ends last, with the lengths among
|
||||||
// among them.
|
// them.
|
||||||
type entries struct {
|
type entries struct {
|
||||||
netblocks map[netip.Prefix]bool
|
netblocks map[netip.Prefix]bool
|
||||||
lengths []int
|
lengths []int
|
||||||
@@ -504,10 +504,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||||
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
||||||
// decision list. In the first two, anything after a ; or a # on a line is
|
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
||||||
// left out, and so is a line left blank. Any other line that does not read
|
// a # on a line is left out, and so is a line left blank. Any other line that
|
||||||
// is an error naming it by its number.
|
// does not read is an error naming it by its number.
|
||||||
func (l *Lists) parse(
|
func (l *Lists) parse(
|
||||||
listURL string, lines []string, fetched time.Time,
|
listURL string, lines []string, fetched time.Time,
|
||||||
) (entries, error) {
|
) (entries, error) {
|
||||||
@@ -521,8 +521,8 @@ func (l *Lists) parse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
||||||
// as the settings take them.
|
// or a netblock as the settings take them.
|
||||||
func parseNetblocks(lines []string) (entries, error) {
|
func parseNetblocks(lines []string) (entries, error) {
|
||||||
found := entries{netblocks: map[netip.Prefix]bool{}}
|
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||||
|
|
||||||
|
|||||||
@@ -19,27 +19,34 @@ const (
|
|||||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||||
// the app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
|
||||||
// ActionTooLarge is a request over the header limits.
|
// ActionTooLarge is a request over the header limits.
|
||||||
ActionTooLarge = "too_large"
|
ActionTooLarge = "too_large"
|
||||||
|
|
||||||
// ActionRateLimited is a request refused because it took its client
|
// ActionRateLimited is a request refused because it took its client
|
||||||
// over a rate limit, which bans the client.
|
// over a rate limit, which bans the client.
|
||||||
ActionRateLimited = "rate_limited"
|
ActionRateLimited = "rate_limited"
|
||||||
|
|
||||||
// ActionBanned is a request refused because a ban covers its client,
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
// or because it matched a ban rule, asked for a trap path or the
|
// or because it matched a ban rule, asked for a trap path or the
|
||||||
// CrowdSec decision list lists its client, each of which bans the
|
// CrowdSec decision list lists its client, each of which bans the
|
||||||
// client.
|
// client.
|
||||||
ActionBanned = "banned"
|
ActionBanned = "banned"
|
||||||
|
|
||||||
// ActionRuleBlocked is a request refused because it matched a block
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// rule.
|
// rule.
|
||||||
ActionRuleBlocked = "rule_blocked"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
|
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
|
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
|
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
// ActionCountryDenied is a request refused because its client's country
|
||||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
@@ -258,7 +265,11 @@ func FormatTime(t time.Time) string {
|
|||||||
|
|
||||||
// Milliseconds is d in milliseconds, to the microsecond.
|
// Milliseconds is d in milliseconds, to the microsecond.
|
||||||
func Milliseconds(d time.Duration) float64 {
|
func Milliseconds(d time.Duration) float64 {
|
||||||
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
return float64(
|
||||||
|
d.Microseconds(),
|
||||||
|
) / float64(
|
||||||
|
time.Millisecond/time.Microsecond,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
@@ -266,17 +277,25 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
func NewProcessLogger(
|
||||||
|
w io.Writer,
|
||||||
|
instanceName string,
|
||||||
|
level slog.Level,
|
||||||
|
) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
Level: level,
|
Level: level,
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
return slog.String(
|
||||||
|
slog.TimeKey,
|
||||||
|
FormatTime(attr.Value.Time()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return attr
|
return attr
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).With("type", "process", "instance", instanceName)
|
return slog.New(handler).
|
||||||
|
With("type", "process", "instance", instanceName)
|
||||||
}
|
}
|
||||||
@@ -618,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWW_TAG_DENY": "country=kp",
|
"SWW_TAG_DENY": "country=KP",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/publish: build the image on the Docker host and push it to the
|
||||||
|
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
|
||||||
|
# hash, the branch name and latest. The build is script/docker's, so the
|
||||||
|
# tests and the linter run first. The host and the registry name are
|
||||||
|
# environment variables with defaults, for running it against another.
|
||||||
|
#
|
||||||
|
# The working tree must be clean: the hash tag names a commit, and an
|
||||||
|
# image built from uncommitted changes would carry a hash that does not
|
||||||
|
# describe it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
|
||||||
|
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
|
||||||
|
export DOCKER_HOST
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`.
|
||||||
|
changes="$(git status --porcelain)"
|
||||||
|
if [ -n "$changes" ]; then
|
||||||
|
echo "script/publish: the working tree is not clean; commit or stash first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
date="$(date -u +%Y-%m-%d)"
|
||||||
|
hash="$(git rev-parse --short HEAD)"
|
||||||
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
if [ "$branch" = "HEAD" ]; then
|
||||||
|
echo "script/publish: detached HEAD; check out a branch first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
|
||||||
|
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||||
|
|
||||||
|
local_tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
"$SCRIPT_DIR/docker"
|
||||||
|
|
||||||
|
for tag in "$date" "$hash" "$branch" latest; do
|
||||||
|
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
for tag in "$date" "$hash" "$branch" latest; do
|
||||||
|
docker push "$PUBLISH_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in new issue
Block a user