From 965936ab4d566a493e6d85da0d22f76b16161f57 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 05:33:51 +0000 Subject: [PATCH] Caches of outside APIs live in /var/cache/sww (closes #314) The answers of GeoJS, the lists fetched by URL, the DNS blocklists, AbuseIPDB and routewatch each go in a cache file in SWW_CACHE_DIR (default /var/cache/sww): lookups.json, lists.json, dnsbl.json, abuseipdb-scores.json and routewatch.json. reputation.json is gone. The AbuseIPDB checks spent today are a count, not a cache, and stay in SWW_STATE_DIR as abuseipdb-checks-spent.json. A cache file never stops the start: one that does not parse is set aside as an edit is, one that cannot be read is written over, and its cache starts empty. An unwritable SWW_CACHE_DIR stops the start, as an unwritable SWW_STATE_DIR does. Each directory is watched on its own. The run script makes the cache directory and gives it to sww. Model: opus-5-5 --- README.md | 44 +- SPEC.md | 127 ++-- docs/alerts.md | 3 +- docs/metrics.md | 4 +- docs/settings.md | 8 +- docs/state-files.md | 91 ++- internal/config/config.go | 10 +- internal/config/config_test.go | 28 +- internal/reputation/abuseipdb.go | 64 +- internal/reputation/abuseipdb_test.go | 31 +- internal/reputation/crowdsec_test.go | 4 +- internal/reputation/dnsbl.go | 6 +- internal/reputation/dnsbl_test.go | 4 +- internal/reputation/reputation.go | 14 +- internal/reputation/reputation_test.go | 6 +- internal/routewatch/routewatch.go | 113 +++- internal/routewatch/routewatch_test.go | 94 +++ internal/state/state.go | 620 +++++++++++++++----- internal/state/state_test.go | 773 +++++++++++++++++++------ internal/sww/sww.go | 2 + internal/sww/sww_test.go | 69 ++- internal/sww/waf_test.go | 1 + internal/waf/crowdsec_test.go | 4 +- internal/waf/proxy.go | 4 +- internal/waf/reputation_test.go | 16 +- script/example-app | 14 +- script/run | 10 +- share/sww.run | 38 +- 28 files changed, 1645 insertions(+), 557 deletions(-) diff --git a/README.md b/README.md index a9c56fd..e7b83db 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,8 @@ Every setting the code reads, with its default: | `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | | `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | -| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | +| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. | +| `SWW_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. | | `SWW_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | | `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. | | `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | @@ -614,15 +615,29 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). while `waf` runs. The image ships one file of probes no real visitor sends (see [`docs/rules.md`](docs/rules.md)). -### State files +### State and cache files -`waf` keeps everything in memory, and the five JSON files in `SWW_STATE_DIR` -hold a copy: `bans.json`, `clients.json`, `lookups.json`, `reputation.json` and -`alerts.json`. Each is written whole, to a temporary file renamed over the real -one, so a crash leaves a complete file. `waf` notices a file edited on disk and -takes the edit in; an edit that does not parse is set aside as `.bad`, and -never stops the running process (see -[`docs/state-files.md`](docs/state-files.md)). +`waf` keeps everything in memory, and JSON files hold a copy: + +- The state files, in `SWW_STATE_DIR`, hold what cannot be fetched again: + - `bans.json`; + - `clients.json`; + - `abuseipdb-checks-spent.json`; + - `alerts.json`. +- The cache files, in `SWW_CACHE_DIR`, hold answers from outside services: + - `lookups.json`, from GeoJS; + - `lists.json`, the lists fetched by URL; + - `dnsbl.json`, from the DNS blocklists; + - `abuseipdb-scores.json`, from AbuseIPDB; + - `routewatch.json`, from routewatch. + +Back up the state directory. The cache directory can be left out of backups: a +lost cache file starts empty and fills again. + +Each file is written whole, to a temporary file renamed over the real one, so a +crash leaves a complete file. `waf` notices a file edited on disk and takes the +edit in. An edit that does not parse is set aside as `.bad`, and never +stops the running process (see [`docs/state-files.md`](docs/state-files.md)). ### The image and its processes @@ -633,7 +648,7 @@ never stops the running process (see - runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532) and starts it again when it exits. The `run` script of `waf` gives the state directory to the `sww` user at each start, so a volume needs no change of - owner. + owner. It makes the cache directory for that user too. - `rproxy` and `waf` use no token between them. `waf`'s own endpoints, under `/_sww/`, are for your admin tools and your metrics collector, at its own address on the private network; nothing routes the internet there. @@ -658,8 +673,8 @@ never stops the running process (see `internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or source each. - `internal/state`, `internal/requestlog`, `internal/remotelog` and - `internal/metrics`: the state files, the log lines, the syslog sender and the - metrics. + `internal/metrics`: the state and cache files, the log lines, the syslog + sender and the metrics. ## Rationale @@ -739,6 +754,7 @@ on the host. `http://127.0.0.1:8081`; - `waf` listens on port 8090; - its state files go in `bin/state`; + - its cache files go in `bin/cache`; - its rule files are those of `share/rules.d`. - `script/example-app`: builds the image and the example app (`deploy/example-app`) on it, runs the app with `rproxy` in its container and @@ -775,8 +791,8 @@ on the host. - [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination gets. - [`docs/metrics.md`](docs/metrics.md): the metrics. -- [`docs/state-files.md`](docs/state-files.md): the state files and what each - holds. +- [`docs/state-files.md`](docs/state-files.md): the state and cache files and + what each holds. - [`docs/image.md`](docs/image.md): what the image holds and how it is pinned. ## License diff --git a/SPEC.md b/SPEC.md index c7c6b57..b2b1cb4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -37,7 +37,7 @@ never as a time. `SWW_`, and those of `rproxy` with `SWRP_`. The only other inputs are `waf`'s files: - the rule files; - - the state files; + - the state files and the cache files; - a lookup database; - a `SWW_` setting given as a file; - the file `SWW_LOG_REMOTE_TLS_CA_FILE` names; @@ -56,19 +56,19 @@ never as a time. ## Architecture - One statically linked Go binary. Only `waf` writes to disk, and only to its - state directory. It ships in one image, which runs either command (see - "Deployment"). + state directory and its cache directory. It ships in one image, which runs + either command (see "Deployment"). - No database of any kind. Nothing is read from disk while serving a request. - Packages: - `cmd/sww`: the binary, which only calls `internal/sww`. - `internal/sww`: the process, as the command it is given. `sww waf` reads - the settings, the rule files, the lookup database and the state files, - listens, and serves until `SIGTERM` or `SIGINT`. Then it stops, writing - the state files (see "`waf`"). `sww rproxy` reads only that command's - settings, and runs `internal/rproxy` until `SIGTERM` or `SIGINT` (see - "`rproxy`"). `sww healthcheck` is the image's health check. With no - command, or one it does not know, the binary prints the commands and exits - with status 2. + the settings, the rule files, the lookup database and the state and cache + files, listens, and serves until `SIGTERM` or `SIGINT`. Then it stops, + writing the state and cache files (see "`waf`"). `sww rproxy` reads only + that command's settings, and runs `internal/rproxy` until `SIGTERM` or + `SIGINT` (see "`rproxy`"). `sww healthcheck` is the image's health check. + With no command, or one it does not know, the binary prints the commands + and exits with status 2. - `internal/config`: reads the settings, the one place they are read. - `internal/waf`: `waf`'s server. It runs the checks in `check`, and holds `waf`'s question, answer and report. It answers `waf`'s own endpoints in @@ -96,8 +96,8 @@ never as a time. - `internal/alerts`: holds back repeats and alerts past the hourly limit, and sends the rest to the webhook, Slack and ntfy, each from a queue of its own. - - `internal/state`: reads the state files at start, takes in edits while - running, and writes the files when due and at the stop. + - `internal/state`: reads the state and cache files at start, takes in edits + while running, and writes the files when due and at the stop. - `internal/requestlog` and `internal/remotelog`: the lines on stdout, and sending them to the syslog server. - `internal/metrics`: the metrics, in the Prometheus text format. @@ -106,7 +106,7 @@ never as a time. container, which `script/example-app` checks with `waf` in another. - Libraries, checked against the owner's Go dependency defaults: - the standard library for the reverse proxy, HTTP clients, DNS, logging - (`log/slog`) and the state files (`encoding/json`, `os.Rename`); + (`log/slog`) and the state and cache files (`encoding/json`, `os.Rename`); - `github.com/corazawaf/coraza/v3` and `github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries the Core Rule Set 4.25.0. The changes `smallwebwaf` makes to it, and the @@ -367,7 +367,7 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions: one, since `rproxy` never touches disk. - Settings, files included, are read once at start. Changing one means restarting the container. The files `waf` watches while it runs are its state - files, its rule files and the lookup database. + and cache files, its rule files and the lookup database. - An invalid setting stops the process at start with one log line, marked `"type":"process"` with `"msg":"invalid setting"`. The line names the variable and what is wrong with the value; "Each container's processes" says what @@ -397,10 +397,10 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions: start. The file form keeps a token out of the container's environment (see "Tokens" under "Deployment"). -### State files +### State and cache files -- `SWW_STATE_DIR` names the directory of the state files (see "State" under - "Deployment"). +- `SWW_STATE_DIR` names the directory of the state files, and `SWW_CACHE_DIR` + that of the cache files (see "State" under "Deployment"). - `bans.json` is written `SWW_STATE_WRITE_DELAY` after a ban is made, lifted or made permanent, so a burst of changes becomes one write. Every file is written every `SWW_STATE_COUNTER_INTERVAL`, and at an orderly stop. @@ -583,7 +583,7 @@ Each tag and where it comes from: - They are added before `deny_nets`, so the log line of a request it refused has them. - `waf` asks routewatch in the background and never waits for it. An answer - is kept for 1 hour, in memory only. + is kept for 1 hour, in memory and in `routewatch.json`. - After a query fails, routewatch is not asked for 1 minute. - A request without these tags gives the reason in the log field `routewatch_tags_missing` (see @@ -1601,8 +1601,8 @@ together. is kept whole, comment lines included, on failure and across restarts. - Each list is fetched again `SWW_BLOCKLIST_REFRESH` after its last try, one - list at a time. `reputation.json` keeps when each was last tried, so a restart - does not fetch a list again before its time. A fetch fails when: + list at a time. `lists.json` keeps when each was last tried, so a restart does + not fetch a list again before its time. A fetch fails when: - the server answers other than `200`; - the fetch does not finish within a minute; - the list is longer than 16 MiB; @@ -1646,9 +1646,9 @@ together. DNS blocklists made for mail servers list residential ranges, and would block ordinary visitors, so they should not be used. Spamhaus zones need their keyed query service, given as a zone name that contains the key. `waf` - shows the key as `********` wherever it names the zone. Only - `reputation.json` keeps the key. `SWW_DNSBL_RESOLVER` names a resolver, - since public resolvers are refused by several list operators. + shows the key as `********` wherever it names the zone. Only `dnsbl.json` + keeps the key. `SWW_DNSBL_RESOLVER` names a resolver, since public resolvers + are refused by several list operators. - AbuseIPDB (`SWW_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the abuse confidence score of a client, from 0 to 100. Only a client that has already @@ -1659,11 +1659,11 @@ together. client's later requests. For an IPv6 client, AbuseIPDB is asked about the address of the request that set off the check, and the score is used for the whole group. A score at or over `SWW_ABUSEIPDB_MIN_SCORE` is a hit. The budget - is counted in UTC days and kept in `reputation.json`, so a restart does not - make it whole again. The check that spends the last of it is logged and - alerted, and no client is checked until the day ends. The key is sent in the - `Key` header and nowhere else, and is never logged. A check fails, after which - no client is checked for a minute, when: + is counted in UTC days and kept in `abuseipdb-checks-spent.json`, so a restart + does not make it whole again. The check that spends the last of it is logged + and alerted, and no client is checked until the day ends. The key is sent in + the `Key` header and nowhere else, and is never logged. A check fails, after + which no client is checked for a minute, when: - AbuseIPDB answers other than `200`; - the answer gives no score; - no answer comes within `SWW_REPUTATION_TIMEOUT`. @@ -1671,9 +1671,9 @@ together. by default. Such verdicts are less certain than a blocklist, so a listed client gets a quarter of every request and byte limit rather than a refusal. - Verdicts and scores are kept for `SWW_REPUTATION_CACHE_TTL`, in memory and in - `reputation.json`. A source that is down or over quota gives no verdict. The - service continues. Each failure raises a `source_failure` alert (see - "Alerting"). + `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over quota + gives no verdict. The service continues. Each failure raises a + `source_failure` alert (see "Alerting"). ### Alerting @@ -1776,21 +1776,43 @@ default file. ## Persistent state -All state lives in memory, and the files in `SWW_STATE_DIR` hold a copy of it, -so an orderly stop loses nothing. The files are meant for people as well: an -admin can read or edit them at any time, and the running `waf` takes the edit -in. `docs/state-files.md` lists the files, what each holds, and their sizes. +All state lives in memory, and JSON files hold a copy of it, so an orderly stop +loses nothing. The files are meant for people as well: an admin can read or edit +them at any time, and the running `waf` takes the edit in. `docs/state-files.md` +lists the files, what each holds, and their sizes. +- The state files, in `SWW_STATE_DIR`, hold what cannot be fetched again: + - `bans.json`; + - `clients.json`; + - `abuseipdb-checks-spent.json`; + - `alerts.json`. +- The cache files, in `SWW_CACHE_DIR`, hold the answers of outside services: + - `lookups.json`; + - `lists.json`; + - `dnsbl.json`; + - `abuseipdb-scores.json`; + - `routewatch.json`. +- A lost cache file costs only questions asked again, so the cache directory can + be left out of backups. - Each file holds one kind of state, as JSON with: - a top-level `version` number; - entries sorted by client address; - times in RFC 3339 UTC; - days as `YYYY-MM-DD`; - durations and sizes as plain numbers, with the unit in the field name. -- `bans.json`, `reputation.json` and `alerts.json` are indented, so that a - person can open `bans.json` in an editor, find an address, and remove or add - an entry. `clients.json` and `lookups.json` put each entry on one line, which - halves their size and lets `grep` show everything about one client. +- These files are indented, so that a person can open `bans.json` in an editor, + find an address, and remove or add an entry: + - `bans.json`; + - `abuseipdb-checks-spent.json`; + - `alerts.json`; + - `lists.json`. +- The others put each entry on one line, which halves their size and lets `grep` + show everything about one client: + - `clients.json`; + - `lookups.json`; + - `dnsbl.json`; + - `abuseipdb-scores.json`; + - `routewatch.json`. - Clients that were never banned are kept too, so every client's history survives a restart. - Each file has a bound, so it can be rewritten whole. Writing one takes well @@ -1804,8 +1826,8 @@ in. `docs/state-files.md` lists the files, what each holds, and their sizes. disk, so an admin's edit is never overwritten. - At start, the files are read back. Active bans go into an in-memory lookup by netblock, everything else into maps. Expired cache entries are discarded. A - missing file means empty state, and is normal on first run. These stop the - process with a message naming the file and position: + missing file means empty state, and is normal on first run. In a state file, + these stop the process with a message naming the file and position: - a file that does not parse, or has an unknown `version`; - an entry without a field it needs; - a ban whose cause is not one of the four; @@ -1815,6 +1837,11 @@ in. `docs/state-files.md` lists the files, what each holds, and their sizes. Since writes are atomic, a broken file can only come from a hand edit, which the editor should hear about. +- A cache file never stops the start. One that does not parse is set aside as + `.bad`, as an edit is below. One that cannot be read is logged and + written over at its next write. Either way its cache starts empty and fills + again. + - While running, `waf` notices a file that is edited, replaced or added. It tells its own writes from an admin's by comparing the file with what it last read or wrote. An edit that parses is taken in at once: what the file says @@ -1905,10 +1932,10 @@ each app. `rproxy` asks `waf` about each visitor's request. `waf` answers This section is the agreement between the two. The header names, the fields and the statuses below are exact. -`waf` reads the settings, the rule files, the lookup database and the state -files, and listens at `SWW_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it stops +`waf` reads the settings, the rule files, the lookup database and the state and +cache files, and listens at `SWW_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it stops taking requests and gives those in progress five seconds to finish. It then -writes the state files. +writes the state and cache files. ### The question @@ -2480,8 +2507,10 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start `SWW_COMMAND` is `waf`, and `rproxy` when it is `rproxy`, unset or empty. It runs either as the `sww` user (uid and gid 65532), with runit's `chpst`. For `waf`, it first makes `SWW_STATE_DIR` and every file in it belong to that - user. For `rproxy` it changes no owner. With any other value, it starts - nothing, and stops with a message naming `SWW_COMMAND` and its value. + user. It also makes `SWW_CACHE_DIR` if it is missing, and gives it and every + file in it to that user. For `rproxy` it changes no owner. With any other + value, it starts nothing, and stops with a message naming `SWW_COMMAND` and + its value. Every `run` script, the app's included, is a bash script. It starts with `#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main` @@ -2504,7 +2533,7 @@ process that stops at start is started again every second, not in a tight loop. - invalid configuration; - a lookup database that is missing or unreadable; - a rule file or state file that does not parse; - - an unwritable `SWW_STATE_DIR`; + - an unwritable `SWW_STATE_DIR` or `SWW_CACHE_DIR`; - a `SWW_RULES_DIR` that does not exist or cannot be read; - a `SWW_LISTEN_ADDR` it cannot listen on. - Only an invalid setting, or a `SWRP_LISTEN_ADDR` it cannot listen on, stops @@ -2573,6 +2602,10 @@ needs no backup beyond the host's own. The image declares no volume, since every app image built on it would inherit it. `rproxy` keeps nothing, and needs no volume. +`waf` keeps its cache files in `SWW_CACHE_DIR`, `/var/cache/sww` by default, +which the `run` script makes. The cache directory needs no volume and can be +left out of backups: a new container starts with empty caches, which fill again. + ### Tokens A token given as a file (`SWW_ADMIN_TOKEN_FILE`, `SWW_METRICS_TOKEN_FILE`) stays diff --git a/docs/alerts.md b/docs/alerts.md index 98d2f2d..9e97db5 100644 --- a/docs/alerts.md +++ b/docs/alerts.md @@ -23,7 +23,8 @@ back or dropped. - the day's AbuseIPDB checks used up. - `file_error`: one of: - a rule file or state file edit with an error; - - a state file that cannot be written; + - a cache file that does not parse, at start or after an edit; + - a state file or cache file that cannot be written; - a replacement lookup database that cannot be read. - `summary`: the hourly summary of alerts held back. diff --git a/docs/metrics.md b/docs/metrics.md index ccd4cd5..7413de1 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -58,9 +58,9 @@ By `source`: each list's URL, each zone, and `abuseipdb`: - `sww_reputation_last_fetch_timestamp_seconds`, for a list; - `sww_reputation_daily_budget_remaining`, for AbuseIPDB. -## State files +## State and cache files -By `file`: +By `file`, the name of a state or cache file: - `sww_state_file_writes_total`, `sww_state_file_write_failures_total`, `sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`; diff --git a/docs/settings.md b/docs/settings.md index 5abd9a7..945ba3c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -50,14 +50,16 @@ the two (see "The image's command" below). - `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least 32 characters. -## State files +## State and cache files - `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an absolute path that exists and can be written. - `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be written after a ban is made, lifted or made permanent. A duration. -- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - written. A duration. +- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and cache + file is written. A duration. +- `SWW_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache files, + an absolute path that can be written. The image's `run` script makes it. ## Logging diff --git a/docs/state-files.md b/docs/state-files.md index 28a1a68..225e0ad 100644 --- a/docs/state-files.md +++ b/docs/state-files.md @@ -1,8 +1,23 @@ -# State files +# State and cache files -The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state" -in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has -a top-level `version` of 1. +The JSON files `waf` keeps, and what each holds. "Persistent state" in +[`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has a +top-level `version` of 1. + +- The state files are in `SWW_STATE_DIR`, `/var/lib/sww` by default. They hold + what cannot be fetched again, so back them up: + - `bans.json`; + - `clients.json`; + - `abuseipdb-checks-spent.json`; + - `alerts.json`. +- The cache files are in `SWW_CACHE_DIR`, `/var/cache/sww` by default. They hold + answers from outside services, which are asked again when a file is lost, so + the directory can be left out of backups: + - `lookups.json`; + - `lists.json`; + - `dnsbl.json`; + - `abuseipdb-scores.json`; + - `routewatch.json`. ## `bans.json` @@ -67,21 +82,13 @@ since it was first seen: `blocked_by_core_rule_set`. A file may also hold `token_refused`, which is kept, and which nothing adds to. -## `lookups.json` +## `abuseipdb-checks-spent.json` -GeoJS answers, one per line: +The AbuseIPDB checks spent today, so that a restart does not spend the daily +budget again: -- `client`; -- `asn`, `as_name` and `country`; -- `answered`: when GeoJS answered; -- `used`: when the answer was last used. - -## `reputation.json` - -- the last good copy of each list fetched from a URL, with when it was last - tried; -- each DNS blocklist verdict still in use; -- AbuseIPDB's scores, and the checks spent today. +- `day`: the day, in UTC; +- `spent`: the checks spent on it. ## `alerts.json` @@ -91,6 +98,51 @@ GeoJS answers, one per line: - the alerts sent and held back in the current hour; - the alerts waiting for each destination. +## `lookups.json` + +A cache file. GeoJS answers, one per line: + +- `client`; +- `asn`, `as_name` and `country`; +- `answered`: when GeoJS answered; +- `used`: when the answer was last used. + +## `lists.json` + +A cache file. The last good copy of each list fetched from a URL, with when it +was last tried: + +- `url`; +- `tried`: when it was last fetched or tried; +- `fetched` and `lines`: when the last good copy was fetched, and its lines, + comment lines included. + +## `dnsbl.json` + +A cache file. Each DNS blocklist verdict still in use, one per line: + +- `zone` and `client`; +- `listed`: whether the zone lists the client; +- `fetched`: when the zone answered. + +## `abuseipdb-scores.json` + +A cache file. AbuseIPDB's scores still in use, one per line: + +- `client`; +- `score`: from 0 to 100; +- `fetched`: when AbuseIPDB answered. + +## `routewatch.json` + +A cache file. routewatch's answers from the last hour, one per line: + +- `client`; +- `netblock`: the netblock the client's address is announced in, empty for an + address without a live route; +- `asn`: the number of the AS that originates the netblock, 0 for none; +- `answered`: when routewatch answered. + ## Sizes - `clients.json`: about 1 KiB per client, about 20 MiB at the default @@ -102,4 +154,7 @@ GeoJS answers, one per line: more. - `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when full. -- `reputation.json` and `alerts.json`: usually a few MiB or less. +- `routewatch.json`: about 120 bytes per answer, at most about 12 MiB. +- `abuseipdb-checks-spent.json`: a few bytes. +- `lists.json`, `dnsbl.json`, `abuseipdb-scores.json` and `alerts.json`: usually + a few MiB or less. diff --git a/internal/config/config.go b/internal/config/config.go index 1765836..be04461 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -191,12 +191,15 @@ type Config struct { BanScopeV4Prefix int // StateDir is the directory of the state files, an absolute path // (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban - // is made (SWW_STATE_WRITE_DELAY), and every state file every - // StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be - // off. + // is made (SWW_STATE_WRITE_DELAY), and every state and cache file + // every StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither + // can be off. StateDir string StateWriteDelay time.Duration StateCounterInterval time.Duration + // CacheDir is the directory of the cache files, which keep answers + // from outside services, an absolute path (SWW_CACHE_DIR). + CacheDir string // LogRequestHeaders are the request headers whose values the request // log gives, in lower case (SWW_LOG_REQUEST_HEADERS). LogRequestHeaders []string @@ -560,6 +563,7 @@ func FromEnvironment( StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"), StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"), StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"), + CacheDir: env.absolutePath("SWW_CACHE_DIR", "/var/cache/sww"), LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS", "accept,accept-language,accept-encoding,content-type,origin,range"), LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 47a460a..85bb141 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -74,6 +74,7 @@ const ( stateDir = "SWW_STATE_DIR" stateWriteDelay = "SWW_STATE_WRITE_DELAY" stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" + cacheDir = "SWW_CACHE_DIR" adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name metricsTopN = "SWW_METRICS_TOP_N" @@ -262,6 +263,7 @@ func TestDefaults(t *testing.T) { StateDir: "/var/lib/sww", StateWriteDelay: 10 * time.Second, StateCounterInterval: 15 * time.Minute, + CacheDir: "/var/cache/sww", MetricsToken: "", MetricsTopN: 50, RulesDir: "/etc/sww/rules.d", @@ -346,6 +348,7 @@ func TestValuesAsSet(t *testing.T) { stateDir: "/srv/waf-state", stateWriteDelay: "500ms", stateCounterInterval: "1h", + cacheDir: "/srv/waf-cache", metricsToken: token, metricsTopN: "10", rulesDir: "/srv/waf-rules", @@ -368,6 +371,7 @@ func TestValuesAsSet(t *testing.T) { StateDir: "/srv/waf-state", StateWriteDelay: 500 * time.Millisecond, StateCounterInterval: time.Hour, + CacheDir: "/srv/waf-cache", MetricsToken: token, MetricsTopN: 10, RulesDir: "/srv/waf-rules", @@ -2302,6 +2306,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateCounterInterval, off}, {stateCounterInterval, "15"}, + {cacheDir, ""}, {cacheDir, "cache"}, {cacheDir, "./var/cache/sww"}, }) } @@ -2574,6 +2579,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { stateDir: "/var/lib/sww", stateWriteDelay: "10s", stateCounterInterval: "15m", + cacheDir: "/var/cache/sww", adminToken: "", metricsToken: "", metricsTopN: "50", @@ -2674,8 +2680,8 @@ func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) { } } -// wantBanSettings checks the settings for bans, the state files, the -// metrics and the rule files. +// wantBanSettings checks the settings for bans, the state and cache +// files, the metrics and the rule files. func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { t.Helper() @@ -2694,11 +2700,7 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled) } - if got.StateDir != want.StateDir || - got.StateWriteDelay != want.StateWriteDelay || - got.StateCounterInterval != want.StateCounterInterval { - t.Errorf("state settings\n%+v\nwant\n%+v", got, want) - } + wantStateSettings(t, got, want) if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN { t.Errorf("metrics token %q and top %d, want %q and %d", @@ -2706,6 +2708,18 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) { } } +// wantStateSettings checks the settings for the state and cache files. +func wantStateSettings(t *testing.T, got *config.Config, want config.Config) { + t.Helper() + + if got.StateDir != want.StateDir || + got.StateWriteDelay != want.StateWriteDelay || + got.StateCounterInterval != want.StateCounterInterval || + got.CacheDir != want.CacheDir { + t.Errorf("state settings\n%+v\nwant\n%+v", got, want) + } +} + // wantNetblocks checks a list of netblocks. func wantNetblocks(t *testing.T, got []netip.Prefix, want ...string) { t.Helper() diff --git a/internal/reputation/abuseipdb.go b/internal/reputation/abuseipdb.go index 6ee8c59..a1fc107 100644 --- a/internal/reputation/abuseipdb.go +++ b/internal/reputation/abuseipdb.go @@ -36,22 +36,21 @@ var ( "checks spent; none is made until the day ends at 00:00 UTC") ) -// Score is what AbuseIPDB said about a client, as reputation.json holds -// it: the client, an IPv4 address or an IPv6 group, its abuse confidence -// score, from 0 to 100, and when AbuseIPDB answered. +// Score is what AbuseIPDB said about a client, as abuseipdb-scores.json +// holds it: the client, an IPv4 address or an IPv6 group, its abuse +// confidence score, from 0 to 100, and when AbuseIPDB answered. type Score struct { Client netip.Prefix `json:"client"` Score int64 `json:"score"` Fetched time.Time `json:"fetched"` } -// Checks are what reputation.json keeps of the checks of clients with -// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the -// first, and the scores still in use. -type Checks struct { - Day civil.Date `json:"day,omitzero"` - Spent int `json:"spent"` - Scores []Score `json:"scores"` +// ChecksSpent is what abuseipdb-checks-spent.json keeps of the checks of +// clients with AbuseIPDB: the day, in UTC, of the checks Spent counts, +// zero before the first. +type ChecksSpent struct { + Day civil.Date + Spent int } // AbuseIPDBParams are what NewAbuseIPDB needs. @@ -196,35 +195,35 @@ func (a *AbuseIPDB) BudgetLeft() int { return max(a.params.DailyBudget-a.spent, 0) } -// Snapshot returns the checks spent and every score still in use, sorted -// by client, as reputation.json keeps them. -func (a *AbuseIPDB) Snapshot() Checks { +// Snapshot returns every score still in use, sorted by client, as +// abuseipdb-scores.json keeps them. +func (a *AbuseIPDB) Snapshot() []Score { a.mu.Lock() now := a.params.Now() - checks := Checks{Day: a.day, Spent: a.spent, Scores: make([]Score, 0, a.scores.Len())} + scores := make([]Score, 0, a.scores.Len()) for _, kept := range a.scores.Values() { if now.Sub(kept.Fetched) < a.params.CacheTTL { - checks.Scores = append(checks.Scores, kept) + scores = append(scores, kept) } } a.mu.Unlock() - slices.SortFunc(checks.Scores, func(x, y Score) int { + slices.SortFunc(scores, func(x, y Score) int { return x.Client.Compare(y.Client) }) - return checks + return scores } -// Load keeps checks, read from reputation.json, in place of those it -// keeps, but for the scores past maxVerdicts, those fetched longest ago. -// One fetched CacheTTL ago or more is neither used nor written, as for any -// score. -func (a *AbuseIPDB) Load(checks Checks) { - scores := slices.Clone(checks.Scores) +// Load keeps scores, read from abuseipdb-scores.json, in place of those +// it keeps, but for the scores past maxVerdicts, those fetched longest +// ago. One fetched CacheTTL ago or more is neither used nor written, as +// for any score. +func (a *AbuseIPDB) Load(scores []Score) { + scores = slices.Clone(scores) slices.SortStableFunc(scores, func(x, y Score) int { return x.Fetched.Compare(y.Fetched) }) @@ -232,7 +231,6 @@ func (a *AbuseIPDB) Load(checks Checks) { a.mu.Lock() defer a.mu.Unlock() - a.day, a.spent = checks.Day, checks.Spent a.scores.Purge() for _, kept := range scores { @@ -240,6 +238,24 @@ func (a *AbuseIPDB) Load(checks Checks) { } } +// ChecksSpent returns the checks spent, as abuseipdb-checks-spent.json +// keeps them. +func (a *AbuseIPDB) ChecksSpent() ChecksSpent { + a.mu.Lock() + defer a.mu.Unlock() + + return ChecksSpent{Day: a.day, Spent: a.spent} +} + +// LoadChecksSpent keeps checks, read from abuseipdb-checks-spent.json, in +// place of the checks spent. +func (a *AbuseIPDB) LoadChecksSpent(checks ChecksSpent) { + a.mu.Lock() + defer a.mu.Unlock() + + a.day, a.spent = checks.Day, checks.Spent +} + // check checks client with AbuseIPDB by addr, one of its addresses, keeps // the score as client's, and notes the check as no longer under way. A // check that fails gives no score: it is counted, logged and raised as a diff --git a/internal/reputation/abuseipdb_test.go b/internal/reputation/abuseipdb_test.go index bddce1d..c0b971a 100644 --- a/internal/reputation/abuseipdb_test.go +++ b/internal/reputation/abuseipdb_test.go @@ -17,6 +17,7 @@ import ( "testing/synctest" "time" + "cloud.google.com/go/civil" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/reputation" @@ -179,11 +180,13 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) { t.Errorf("logged\n%s\nwant the budget used up", log.String()) } - // Restarted with what reputation.json keeps, it uses the scores, and - // checks no client until the day ends. + // Restarted with what abuseipdb-scores.json and + // abuseipdb-checks-spent.json keep, it uses the scores, and checks + // no client until the day ends. restarted := &abuseIPDBStandIn{} again := newAbuseIPDB(restarted, p) again.Load(checker.Snapshot()) + again.LoadChecksSpent(checker.ChecksSpent()) wantScore(t, again, suspect, true, 100, true) wantBudgetLeft(t, again, 0) @@ -224,13 +227,9 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) { time.Sleep(2 * time.Hour) wantBudgetLeft(t, checker, 0) - encoded, err := json.Marshal(checker.Snapshot()) - if err != nil { - t.Fatalf("encode: %v", err) - } - - if !strings.Contains(string(encoded), `"day":"2000-01-01"`) { - t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded) + want := civil.Date{Year: 2000, Month: 1, Day: 1} + if day := checker.ChecksSpent().Day; day != want { + t.Errorf("checks spent on %s, want on %s", day, want) } // At 00:00 UTC the budget is whole again. @@ -302,7 +301,7 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) { wantChecked(t, abuseIPDB, suspect, other) wantFailures(t, checker, 2) - if scores := checker.Snapshot().Scores; len(scores) != 0 { + if scores := checker.Snapshot(); len(scores) != 0 { t.Errorf("scores %+v, want none", scores) } @@ -399,7 +398,7 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) { for name, shown := range map[string]string{ "the log": log.String(), "the alerts": string(alerted), - "the metrics": scrapeMetrics(t, m), "reputation.json": string(kept), + "the metrics": scrapeMetrics(t, m), "abuseipdb-scores.json": string(kept), } { if strings.Contains(shown, key) { t.Errorf("%s shows the key:\n%s", name, shown) @@ -462,12 +461,12 @@ func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) { Fetched: now.Add(-cacheTTL), } - checker.Load(reputation.Checks{Scores: []reputation.Score{stale, inUse}}) + checker.Load([]reputation.Score{stale, inUse}) wantScore(t, checker, suspect, false, 100, true) wantScore(t, checker, other, false, 0, false) - got := checker.Snapshot().Scores + got := checker.Snapshot() if !reflect.DeepEqual(got, []reputation.Score{inUse}) { t.Errorf("scores %+v, want only %+v", got, inUse) } @@ -481,7 +480,7 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) p.Now = func() time.Time { return now } checker := reputation.NewAbuseIPDB(p) - // 100,001 scores, listed by client, as reputation.json lists them, each + // 100,001 scores, listed by client, as abuseipdb-scores.json lists them, each // fetched a millisecond before the one before it: the last is one too // many. const count = 100001 @@ -497,9 +496,9 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) addr = addr.Next() } - checker.Load(reputation.Checks{Scores: scores}) + checker.Load(scores) - got := checker.Snapshot().Scores + got := checker.Snapshot() if len(got) != count-1 || !slices.Contains(got, scores[0]) || slices.Contains(got, scores[count-1]) { t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got)) diff --git a/internal/reputation/crowdsec_test.go b/internal/reputation/crowdsec_test.go index 0978b09..2ae9268 100644 --- a/internal/reputation/crowdsec_test.go +++ b/internal/reputation/crowdsec_test.go @@ -291,7 +291,7 @@ func crowdSecFailures() []crowdSecFailure { // other than its decision list, such as the one a redirect names, and that // the key is in none of what the fetches leave behind: log, the process // log, the alerts waiting in queue, and the copies of lists, which -// reputation.json keeps. +// lists.json keeps. func wantKeyNotShown( t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue, ) { @@ -326,7 +326,7 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing lists := start(t, e, crowdSecParams()) kept := lists.Snapshot() - // Restarted half an hour later with what reputation.json keeps, and + // Restarted half an hour later with what lists.json keeps, and // the engine down, the decision still bans, until the end it had at // the fetch, half an hour on. time.Sleep(30 * time.Minute) diff --git a/internal/reputation/dnsbl.go b/internal/reputation/dnsbl.go index 83cd2a4..c48be94 100644 --- a/internal/reputation/dnsbl.go +++ b/internal/reputation/dnsbl.go @@ -40,7 +40,7 @@ var ( errNotListing = errors.New("the answer is outside 127.0.0.0/8") ) -// Verdict is what a zone said about a client, as reputation.json holds +// Verdict is what a zone said about a client, as dnsbl.json holds // it: the zone, the client's address, whether the zone lists it, and when // the zone answered. type Verdict struct { @@ -187,7 +187,7 @@ func (d *DNSBL) Failures(zone string) int { } // Snapshot returns every verdict still in use, sorted by client, then by -// zone, as reputation.json lists them. +// zone, as dnsbl.json lists them. func (d *DNSBL) Snapshot() []Verdict { d.mu.Lock() @@ -209,7 +209,7 @@ func (d *DNSBL) Snapshot() []Verdict { return verdicts } -// Load keeps verdicts, read from reputation.json, in place of those it +// Load keeps verdicts, read from dnsbl.json, in place of those it // keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and, // past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or // more is neither used nor written, as for any verdict. diff --git a/internal/reputation/dnsbl_test.go b/internal/reputation/dnsbl_test.go index bdf4f98..22bf333 100644 --- a/internal/reputation/dnsbl_test.go +++ b/internal/reputation/dnsbl_test.go @@ -389,7 +389,7 @@ func TestVerdictsKeptAcrossARestart(t *testing.T) { t.Errorf("verdicts %+v, want %+v", kept, want) } - // Restarted an hour later with what reputation.json keeps, it uses + // Restarted an hour later with what dnsbl.json keeps, it uses // the verdicts, and asks the zone nothing, until the TTL has passed // since they were fetched. time.Sleep(time.Hour) @@ -446,7 +446,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing. p.Now = func() time.Time { return now } dnsbl := reputation.NewDNSBL(p) - // 100,001 verdicts, listed by client, as reputation.json lists them, + // 100,001 verdicts, listed by client, as dnsbl.json lists them, // each fetched a millisecond before the one before it: the last is one // too many. const count = 100001 diff --git a/internal/reputation/reputation.go b/internal/reputation/reputation.go index 64db688..f1293df 100644 --- a/internal/reputation/reputation.go +++ b/internal/reputation/reputation.go @@ -7,8 +7,8 @@ // and when each was last tried. It also asks the DNSBL zones of // SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks // clients with AbuseIPDB, and keeps their scores and the checks spent -// today. The state package writes all of these to reputation.json and -// reads them from it, so that a restart keeps them too. +// today. The state package writes all of these to files and reads them +// from there, so that a restart keeps them too. package reputation import ( @@ -54,7 +54,7 @@ var ( "does not give an address or a netblock and a duration, such as 4h0m0s") ) -// List is a list as reputation.json holds it: the URL it is fetched from, +// List is a list as lists.json holds it: the URL it is fetched from, // when it was last tried, the fetch failed or not, and its last good copy: // when that was fetched, and its lines, as fetched, comment lines // included, both left out while no fetch of it has succeeded. @@ -106,7 +106,7 @@ type Lists struct { lists map[string]*list } -// list is one list: what reputation.json keeps of it, its last try, zero +// list is one list: what lists.json keeps of it, its last try, zero // before the first, and its last good copy, what that copy says, and how // many fetches of it failed. type list struct { @@ -272,7 +272,7 @@ func (l *Lists) Failures(listURL string) int { // Run fetches each list once it is due, as due tells, until ctx is done. A // list never tried is fetched at once, and so is one that is due by its -// last try or copy read from reputation.json. +// last try or copy read from lists.json. func (l *Lists) Run(ctx context.Context) { if len(l.lists) == 0 { return @@ -292,7 +292,7 @@ func (l *Lists) Run(ctx context.Context) { } // Snapshot returns each list that has been tried, with its copy, if it -// has one, sorted by URL, as reputation.json lists them. +// has one, sorted by URL, as lists.json lists them. func (l *Lists) Snapshot() []List { l.mu.Lock() @@ -313,7 +313,7 @@ func (l *Lists) Snapshot() []List { return tried } -// Load puts lists, read from reputation.json, in place of the last tries +// Load puts lists, read from lists.json, in place of the last tries // and copies held. A list Params does not name is dropped. A copy with a // line that parse refuses is an error, and then nothing changes. func (l *Lists) Load(lists []List) error { diff --git a/internal/reputation/reputation_test.go b/internal/reputation/reputation_test.go index 3a9011d..3af0448 100644 --- a/internal/reputation/reputation_test.go +++ b/internal/reputation/reputation_test.go @@ -260,7 +260,7 @@ func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing. lists.SetTransport(servers) // drop.txt was fetched an hour ago, and tor.txt a refresh ago, as - // reputation.json says at start. + // lists.json says at start. err := lists.Load([]reputation.List{ {URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}}, {URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}}, @@ -297,7 +297,7 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) { time.Sleep(refresh) wantFetches(t, servers, 2) - // Restarted with what reputation.json keeps, it waits a refresh + // Restarted with what lists.json keeps, it waits a refresh // after the failed try, as it does while it runs. restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}} again := reputation.New(params(dropURL)) @@ -353,7 +353,7 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) { waiting(queue)) } - // Restarted an hour later with what reputation.json keeps, it fetches + // Restarted an hour later with what lists.json keeps, it fetches // tor.txt, never tried, at once, and drop.txt a refresh after its // cut-off try. time.Sleep(time.Hour) diff --git a/internal/routewatch/routewatch.go b/internal/routewatch/routewatch.go index ed4e701..d974b5b 100644 --- a/internal/routewatch/routewatch.go +++ b/internal/routewatch/routewatch.go @@ -1,6 +1,8 @@ // Package routewatch asks a routewatch instance about client addresses, in the -// background, and keeps its answers in memory. A routewatch instance knows, for -// an address, the netblock it is announced in and the AS that originates it. +// background, and keeps its answers. A routewatch instance knows, for an +// address, the netblock it is announced in and the AS that originates it. The +// answers are kept in memory, for at most 100,000 clients and for an hour each, +// and are written to routewatch.json and read from it by the state package. package routewatch import ( @@ -13,6 +15,7 @@ import ( "net/http" "net/netip" "net/url" + "slices" "sync" "time" @@ -65,6 +68,17 @@ type Route struct { ASN uint32 } +// Answer is what routewatch answered about a client, as routewatch.json holds +// it: the netblock the client's address is announced in, none for an address +// without a live route, the AS that originates it, and when routewatch +// answered. +type Answer struct { + Client netip.Prefix `json:"client"` + Netblock netip.Prefix `json:"netblock"` + ASN uint32 `json:"asn"` + Answered time.Time `json:"answered"` +} + // Params are what New needs. type Params struct { // URL is where routewatch is (SWW_ROUTEWATCH_URL). @@ -84,23 +98,16 @@ type Client struct { mu sync.Mutex // answers are by client. Each is added as it comes and never moved up, // so that the one that came longest ago is the first dropped. - answers *simplelru.LRU[netip.Prefix, answer] + answers *simplelru.LRU[netip.Prefix, Answer] // asking are the clients whose query is under way. asking map[netip.Prefix]bool // retryAt is when routewatch may be asked again after a query failed. retryAt time.Time } -// answer is one kept answer: the route, or why there is none, and when it came. -type answer struct { - route Route - missing Missing - came time.Time -} - // New returns a Client with no answer yet. func New(params Params) *Client { - answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil) + answers, err := simplelru.NewLRU[netip.Prefix, Answer](maxAnswers, nil) if err != nil { panic(err) // NewLRU fails only for a size below one } @@ -128,8 +135,12 @@ func (c *Client) Route( now := c.params.Now() kept, found := c.answers.Peek(client) - if found && now.Sub(kept.came) < answerKept { - return kept.route, kept.missing + if found && now.Sub(kept.Answered) < answerKept { + if !kept.Netblock.IsValid() { + return Route{}, NoRoute + } + + return Route{Netblock: kept.Netblock, ASN: kept.ASN}, "" } if now.Before(c.retryAt) { @@ -145,12 +156,58 @@ func (c *Client) Route( return Route{}, NoAnswerYet } +// Snapshot returns every answer still in use, sorted by client, as +// routewatch.json lists them. +func (c *Client) Snapshot() []Answer { + c.mu.Lock() + + now := c.params.Now() + answers := make([]Answer, 0, c.answers.Len()) + + for _, kept := range c.answers.Values() { + if now.Sub(kept.Answered) < answerKept { + answers = append(answers, kept) + } + } + + c.mu.Unlock() + + slices.SortFunc(answers, func(a, b Answer) int { + return a.Client.Compare(b.Client) + }) + + return answers +} + +// Load keeps answers, read from routewatch.json, in place of those it keeps, +// but for those that came answerKept ago or more and, past maxAnswers, those +// that came longest ago. +func (c *Client) Load(answers []Answer) { + answers = slices.Clone(answers) + slices.SortStableFunc(answers, func(a, b Answer) int { + return a.Answered.Compare(b.Answered) + }) + + c.mu.Lock() + defer c.mu.Unlock() + + c.answers.Purge() + + now := c.params.Now() + + for _, kept := range answers { + if now.Sub(kept.Answered) < answerKept { + c.answers.Add(kept.Client, kept) + } + } +} + // ask asks routewatch about addr, keeps the answer as client's, and notes the // query as no longer under way. A query that fails gives no answer, and // routewatch is not asked again for failureKept. Only the first failure of // those under way is logged. func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) { - route, missing, err := c.query(ctx, addr) + route, err := c.query(ctx, addr) now := c.params.Now() c.mu.Lock() @@ -160,7 +217,9 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) firstFailure := err != nil && !now.Before(c.retryAt) if err == nil { - c.answers.Add(client, answer{route: route, missing: missing, came: now}) + c.answers.Add(client, Answer{ + Client: client, Netblock: route.Netblock, ASN: route.ASN, Answered: now, + }) } else { c.retryAt = now.Add(failureKept) } @@ -174,19 +233,19 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) // query asks routewatch's IP info API about addr. An answer other than 200, one // that cannot be read, and none within queryTimeout, fail. A result with no -// netblock is an address without a live route. -func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) { +// netblock is an address without a live route, and gives a Route without one. +func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) { ctx, cancel := context.WithTimeout(ctx, queryTimeout) defer cancel() endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String()) if err != nil { - return Route{}, "", fmt.Errorf("make the URL: %w", err) + return Route{}, fmt.Errorf("make the URL: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { - return Route{}, "", fmt.Errorf("make the request: %w", err) + return Route{}, fmt.Errorf("make the request: %w", err) } req.Header.Set("Accept", "application/json") @@ -195,7 +254,7 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, er if err != nil { // Do's error names the URL, which holds the client's address, which // is not to be logged: only what went wrong is kept. - return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err)) + return Route{}, fmt.Errorf("ask routewatch: %w", errors.Unwrap(err)) } defer func() { @@ -203,14 +262,14 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, er }() if res.StatusCode != http.StatusOK { - return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status) + return Route{}, fmt.Errorf("%w %s", errStatus, res.Status) } return readAnswer(io.LimitReader(res.Body, maxAnswerBytes)) } // readAnswer reads routewatch's answer about one address. -func readAnswer(body io.Reader) (Route, Missing, error) { +func readAnswer(body io.Reader) (Route, error) { var decoded struct { Data struct { Results []struct { @@ -222,22 +281,22 @@ func readAnswer(body io.Reader) (Route, Missing, error) { err := json.NewDecoder(body).Decode(&decoded) if err != nil { - return Route{}, "", fmt.Errorf("read the answer: %w", err) + return Route{}, fmt.Errorf("read the answer: %w", err) } if len(decoded.Data.Results) == 0 { - return Route{}, "", errNoResult + return Route{}, errNoResult } result := decoded.Data.Results[0] if result.Netblock == "" { - return Route{}, NoRoute, nil + return Route{}, nil } netblock, err := netip.ParsePrefix(result.Netblock) if err != nil { - return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock) + return Route{}, fmt.Errorf("%w: %q", errNotNetblock, result.Netblock) } - return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil + return Route{Netblock: netblock.Masked(), ASN: result.ASN}, nil } diff --git a/internal/routewatch/routewatch_test.go b/internal/routewatch/routewatch_test.go index c74b1af..53baced 100644 --- a/internal/routewatch/routewatch_test.go +++ b/internal/routewatch/routewatch_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/netip" + "slices" "strings" "sync" "sync/atomic" @@ -336,3 +337,96 @@ func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) { t.Errorf("%d queries after the answer expired, want 2", got) } } + +func TestSnapshotHoldsTheAnswerUntilItExpires(t *testing.T) { + t.Parallel() + + f := newFixture(t, answering(routedAnswer)) + + f.route() + f.waitFor("") + + want := []routewatch.Answer{{ + Client: netip.MustParsePrefix(clientAddr + "/32"), + Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496, + Answered: f.clk.Now(), + }} + if got := f.client.Snapshot(); !slices.Equal(got, want) { + t.Errorf("snapshot %+v, want %+v", got, want) + } + + f.clk.advance(time.Hour) + + if got := f.client.Snapshot(); len(got) != 0 { + t.Errorf("snapshot %+v an hour after the answer, want none", got) + } +} + +func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) { + t.Parallel() + + f := newFixture(t, answering(routedAnswer)) + now := f.clk.Now() + kept := routewatch.Answer{ + Client: netip.MustParsePrefix(clientAddr + "/32"), + Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497, + Answered: now.Add(-time.Hour + time.Second), + } + stale := routewatch.Answer{ + Client: netip.MustParsePrefix("192.0.2.2/32"), + Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496, + Answered: now.Add(-time.Hour), + } + + f.client.Load([]routewatch.Answer{kept, stale}) + + if got := f.client.Snapshot(); !slices.Equal(got, []routewatch.Answer{kept}) { + t.Errorf("kept %+v, want only the answer that came less than an hour ago", got) + } + + route, missing := f.route() + + want := routewatch.Route{Netblock: kept.Netblock, ASN: kept.ASN} + if route != want || missing != "" { + t.Errorf("route %+v (%q), want %+v", route, missing, want) + } + + if got := f.queries.Load(); got != 0 { + t.Errorf("%d queries for a client with an answer loaded, want none", got) + } +} + +func TestLoadDropsTheAnswerThatCameLongestAgoFirst(t *testing.T) { + t.Parallel() + + const maxAnswers = 100000 + + f := newFixture(t, answering(routedAnswer)) + now := f.clk.Now() + + // routewatch.json lists the answers by client. Here each came a + // millisecond before the one listed before it, so the last listed is + // the one that came longest ago, and the one dropped. + answers := make([]routewatch.Answer, maxAnswers+1) + addr := netip.MustParseAddr("10.0.0.0") + + for i := range answers { + answers[i] = routewatch.Answer{ + Client: netip.PrefixFrom(addr, addr.BitLen()), + Answered: now.Add(-time.Duration(i) * time.Millisecond), + } + addr = addr.Next() + } + + f.client.Load(answers) + + got := f.client.Snapshot() + if len(got) != maxAnswers { + t.Fatalf("%d answers kept, want %d", len(got), maxAnswers) + } + + if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] { + t.Errorf("answers kept from %s to %s, want from %s to %s", got[0].Client, + got[maxAnswers-1].Client, answers[0].Client, answers[maxAnswers-1].Client) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index ed9474a..785ac72 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,13 +1,23 @@ -// Package state keeps sww's state in JSON files in -// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires -// and docs/state-files.md lists them: -// bans.json holds the bans, clients.json each client's counters and -// history, lookups.json GeoJS's answers, reputation.json the last try and -// last good copy of each list fetched from a URL, the DNSBL zones' -// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the -// cooldowns, the hour under way, the alerts waiting for each destination -// and the anomaly counters. Load -// reads them at start, Watch takes in an admin's edit of one while +// Package state keeps sww's state in JSON files, as the "Persistent +// state" section of SPEC.md requires and docs/state-files.md lists them. +// +// The state files are in SWW_STATE_DIR: +// - bans.json holds the bans; +// - clients.json holds each client's counters and history; +// - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today; +// - alerts.json holds the cooldowns, the hour under way, the alerts +// waiting for each destination and the anomaly counters. +// +// The cache files are in SWW_CACHE_DIR. Each keeps answers from an +// outside service, which are fetched again when the file is lost: +// - lookups.json holds GeoJS's answers; +// - lists.json holds the last try and last good copy of each list +// fetched from a URL; +// - dnsbl.json holds the DNSBL zones' verdicts; +// - abuseipdb-scores.json holds AbuseIPDB's scores; +// - routewatch.json holds routewatch's answers. +// +// Load reads them at start, Watch takes in an admin's edit of one while // sww runs, and Run and WriteAll write them. The disk is read and // written outside the parts' locks, which are held only to take a // snapshot or to put in what a file holds, so that no request waits on @@ -31,6 +41,7 @@ import ( "sync" "time" + "cloud.google.com/go/civil" "github.com/fsnotify/fsnotify" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" @@ -39,6 +50,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" + "sneak.berlin/go/smallwebwaf/internal/routewatch" ) // version is the version of the files' format, the only one read. @@ -48,15 +60,42 @@ const version = 1 // hold visitors' addresses. const fileMode = 0o600 -// The state files' names. +// The state files' names, in Dir. const ( - bansJSON = "bans.json" - clientsJSON = "clients.json" - lookupsJSON = "lookups.json" - reputationJSON = "reputation.json" - alertsJSON = "alerts.json" + bansJSON = "bans.json" + clientsJSON = "clients.json" + abuseIPDBChecksSpentJSON = "abuseipdb-checks-spent.json" + alertsJSON = "alerts.json" ) +// The cache files' names, in CacheDir. +const ( + lookupsJSON = "lookups.json" + listsJSON = "lists.json" + dnsblJSON = "dnsbl.json" + abuseIPDBScoresJSON = "abuseipdb-scores.json" + routeWatchJSON = "routewatch.json" +) + +// fileNames returns the names of the state files, then those of the +// cache files, in the order they are written. +func fileNames() []string { + return []string{ + bansJSON, clientsJSON, abuseIPDBChecksSpentJSON, alertsJSON, + lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON, + } +} + +// isCacheFile reports whether name is the name of a cache file. +func isCacheFile(name string) bool { + switch name { + case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON: + return true + default: + return false + } +} + var ( errVersion = errors.New("unknown version") // errMissing is for an entry without a field it needs. @@ -72,24 +111,29 @@ var ( // Params are what Load needs. type Params struct { - // Dir is the directory of the state files (SWW_STATE_DIR). - Dir string + // Dir is the directory of the state files (SWW_STATE_DIR), and + // CacheDir that of the cache files (SWW_CACHE_DIR). + Dir string + CacheDir string // WriteDelay is how long after a ban is made bans.json is written // (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file // is (SWW_STATE_COUNTER_INTERVAL). WriteDelay time.Duration CounterInterval time.Duration - // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies - // hold the state. Alerts also receive a file_error alert for an edit set - // aside, and for a write that fails while sww runs. - Ledger *bans.Ledger - Limiter *ratelimit.Limiter - GeoJS *lookup.GeoJS - Lists *reputation.Lists - DNSBL *reputation.DNSBL - AbuseIPDB *reputation.AbuseIPDB - Alerts *alerts.Queue - Anomalies *anomaly.Counters + // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and + // Anomalies hold the state. RouteWatch is nil while SWW_ROUTEWATCH_URL is + // unset, and routewatch.json then holds no answers. Alerts also receive a + // file_error alert for an edit set aside, and for a write that fails while + // sww runs. + Ledger *bans.Ledger + Limiter *ratelimit.Limiter + GeoJS *lookup.GeoJS + Lists *reputation.Lists + DNSBL *reputation.DNSBL + AbuseIPDB *reputation.AbuseIPDB + RouteWatch *routewatch.Client + Alerts *alerts.Queue + Anomalies *anomaly.Counters // Now tells the time by which the counters' buckets run out, normally // time.Now in UTC. Now func() time.Time @@ -101,7 +145,7 @@ type Params struct { Metrics *metrics.Metrics } -// Files are the state files of a running sww. +// Files are the state and cache files of a running sww. type Files struct { params Params @@ -147,13 +191,38 @@ type lookupsFile struct { Lookups []lookup.Answer `json:"lookups"` } -// reputationFile is reputation.json, indented for an admin to read and -// edit, so that each line of a list's copy is on a line of its own. -type reputationFile struct { - Version int `json:"version"` - Lists []reputation.List `json:"lists"` - Verdicts []reputation.Verdict `json:"verdicts"` - AbuseIPDB reputation.Checks `json:"abuseipdb"` +// abuseIPDBChecksSpentFile is abuseipdb-checks-spent.json, indented for an +// admin to read and edit. +type abuseIPDBChecksSpentFile struct { + Version int `json:"version"` + Day civil.Date `json:"day,omitzero"` + Spent int `json:"spent"` +} + +// listsFile is lists.json, indented for an admin to read and edit, so +// that each line of a list's copy is on a line of its own. +type listsFile struct { + Version int `json:"version"` + Lists []reputation.List `json:"lists"` +} + +// dnsblFile is dnsbl.json, with each verdict on a line of its own. +type dnsblFile struct { + Version int `json:"version"` + Verdicts []reputation.Verdict `json:"verdicts"` +} + +// abuseIPDBScoresFile is abuseipdb-scores.json, with each score on a line +// of its own. +type abuseIPDBScoresFile struct { + Version int `json:"version"` + Scores []reputation.Score `json:"scores"` +} + +// routeWatchFile is routewatch.json, with each answer on a line of its own. +type routeWatchFile struct { + Version int `json:"version"` + Answers []routewatch.Answer `json:"answers"` } // alertsFile is alerts.json, indented for an admin to read and edit. @@ -178,34 +247,45 @@ type stateFile interface { toUTC() } -// Load checks that files can be written in Dir, and reads the state files -// in it into the parts of Params that hold the state. A missing file is -// empty state, as on a first start. A file that does not parse, has an -// unknown version, or has an entry without a field it needs, is an error -// that names the file and, where the JSON decoder tells it, the line and -// column, or else the entry. +// Load checks that files can be written in Dir and CacheDir, and reads the +// files in them into the parts of Params that hold the state. A missing +// file is empty state, as on a first start. A state file that does not +// parse, has an unknown version, or has an entry without a field it +// needs, is an error that names the file and, where the JSON decoder +// tells it, the line and column, or else the entry. A cache file is never +// an error, as readCache describes. func Load(params Params) (*Files, error) { err := checkWritable(params.Dir) if err != nil { return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err) } + err = checkWritable(params.CacheDir) + if err != nil { + return nil, fmt.Errorf("SWW_CACHE_DIR cannot be written: %w", err) + } + f := &Files{params: params, sums: map[string][sha256.Size]byte{}} bansRead, bansErr := f.read(bansJSON) clientsRead, clientsErr := f.read(clientsJSON) - lookupsRead, lookupsErr := f.read(lookupsJSON) - reputationRead, reputationErr := f.read(reputationJSON) + _, checksErr := f.read(abuseIPDBChecksSpentJSON) alertsRead, alertsErr := f.read(alertsJSON) - err = errors.Join(bansErr, clientsErr, lookupsErr, reputationErr, alertsErr) + err = errors.Join(bansErr, clientsErr, checksErr, alertsErr) if err != nil { return nil, err } - params.ProcessLog.Info("read the state files", "directory", params.Dir, - "bans", bansRead, "clients", clientsRead, "lookups", lookupsRead, - "lists", reputationRead, "alerts_waiting", alertsRead) + lookupsRead := f.readCache(lookupsJSON) + listsRead := f.readCache(listsJSON) + f.readCache(dnsblJSON) + f.readCache(abuseIPDBScoresJSON) + f.readCache(routeWatchJSON) + + params.ProcessLog.Info("read the state and cache files", "directory", params.Dir, + "cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead, + "lookups", lookupsRead, "lists", listsRead, "alerts_waiting", alertsRead) return f, nil } @@ -234,69 +314,101 @@ func (f *Files) Run(ctx context.Context) { f.logFailure(bansJSON, f.writeFile(bansJSON)) case <-interval.C: - for _, name := range []string{ - bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON, - } { + for _, name := range fileNames() { f.logFailure(name, f.writeFile(name)) } } } } -// WriteAll writes every state file, as sww stops. A file that +// WriteAll writes every state and cache file, as sww stops. A file that // fails does not keep the others from being written. func (f *Files) WriteAll() error { - return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON), - f.writeFile(lookupsJSON), f.writeFile(reputationJSON), f.writeFile(alertsJSON)) -} + names := fileNames() + errs := make([]error, 0, len(names)) -// Watch watches Dir until ctx is done, and takes in an admin's edit of a -// state file as soon as it is saved: what the file holds replaces what -// sww held for it. An edit that does not parse is left for the -// file's next write, which sets it aside, since a file can be read while -// an editor is still writing it. If Dir cannot be watched, that is -// logged, and an edit is taken in only before its file is written. -func (f *Files) Watch(ctx context.Context) { - watcher, err := fsnotify.NewWatcher() - if err == nil { - defer func() { - _ = watcher.Close() - }() - - err = watcher.Add(f.params.Dir) + for _, name := range names { + errs = append(errs, f.writeFile(name)) } + return errors.Join(errs...) +} + +// Watch watches Dir and CacheDir until ctx is done, and takes in an +// admin's edit of a file as soon as it is saved: what the file holds +// replaces what sww held for it. An edit that does not parse is left for +// the file's next write, which sets it aside, since a file can be read +// while an editor is still writing it. A directory that cannot be watched +// is logged, and the other is still watched. An edit in a directory not +// watched is taken in only before its file is written. +func (f *Files) Watch(ctx context.Context) { + watcher, err := fsnotify.NewWatcher() if err != nil { - f.params.ProcessLog.Error("cannot watch the state files for edits", + f.params.ProcessLog.Error("cannot watch the state and cache files for edits", "error", err.Error()) return } - f.params.ProcessLog.Info("watching the state files for edits", - "directory", f.params.Dir) + defer func() { + _ = watcher.Close() + }() + + var watched []string + + for _, dir := range []struct{ path, failed string }{ + {f.params.Dir, "cannot watch the state files for edits"}, + {f.params.CacheDir, "cannot watch the cache files for edits"}, + } { + err = watcher.Add(dir.path) + if err != nil { + f.params.ProcessLog.Error(dir.failed, "directory", dir.path, + "error", err.Error()) + + continue + } + + watched = append(watched, dir.path) + } + + watching := "watching the state and cache files for edits" + + switch { + case len(watched) == 0: + return + case len(watched) == 1 && watched[0] == f.params.Dir: + watching = "watching the state files for edits" + case len(watched) == 1: + watching = "watching the cache files for edits" + } + + f.params.ProcessLog.Info(watching, "directories", watched) for { select { case <-ctx.Done(): return case event := <-watcher.Events: - switch name := filepath.Base(event.Name); name { - case bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON: + name := filepath.Base(event.Name) + if slices.Contains(fileNames(), name) && + filepath.Join(f.dir(name), name) == event.Name { f.fileChanged(name) } case err = <-watcher.Errors: - f.params.ProcessLog.Warn("watching the state files failed", + f.params.ProcessLog.Warn("watching the state and cache files failed", "error", err.Error()) } } } -// logFailure logs a write of the state file name that failed, and raises +// logFailure logs a write of the file name that failed, and raises // a file_error alert for it. func (f *Files) logFailure(name string, err error) { if err != nil { - const failed = "writing the state files failed" + failed := "writing the state files failed" + if isCacheFile(name) { + failed = "writing the cache files failed" + } // Raised before it is logged, so that the alert is there once the // log line is. @@ -304,14 +416,14 @@ func (f *Files) logFailure(name string, err error) { Event: alerts.EventFileError, Reason: failed, Detail: map[string]any{ - "file": filepath.Join(f.params.Dir, name), "error": err.Error(), + "file": filepath.Join(f.dir(name), name), "error": err.Error(), }, }) f.params.ProcessLog.Error(failed, "error", err.Error()) } } -// fileChanged takes in what the state file name holds, as Watch sees it +// fileChanged takes in what the file name holds, as Watch sees it // change, if that is an edit made since sww last read or wrote // the file. A file that cannot be read or does not parse is left for its // next write. @@ -327,7 +439,7 @@ func (f *Files) fileChanged(name string) { _ = f.takeInEdit(name, data) } -// takeInEdit takes in data, an edit of the state file name, as takeIn +// takeInEdit takes in data, an edit of the file name, as takeIn // does, and counts and logs it. Every edit taken in while sww // runs, by Watch or by a write, is taken in here. An edit that does not // parse is neither counted nor logged, and takeIn's error returned. @@ -337,17 +449,31 @@ func (f *Files) takeInEdit(name string, data []byte) error { return err } + tookIn := "took in an edit of a state file" + if isCacheFile(name) { + tookIn = "took in an edit of a cache file" + } + // Counted before it is logged, so that the count is there once the // log line is. f.params.Metrics.StateFileEditTakenIn(name) - f.params.ProcessLog.Info("took in an edit of a state file", - "file", filepath.Join(f.params.Dir, name)) + f.params.ProcessLog.Info(tookIn, "file", filepath.Join(f.dir(name), name)) return nil } -// read takes in the state file name at start, and returns how many -// entries it holds. A missing file holds none. +// dir returns the directory of the file name: CacheDir for a cache file, +// and Dir for a state file. +func (f *Files) dir(name string) string { + if isCacheFile(name) { + return f.params.CacheDir + } + + return f.params.Dir +} + +// read takes in the file name at start, and returns how many entries it +// holds. A missing file holds none. func (f *Files) read(name string) (int, error) { data, changed, err := f.readChanged(name) if err != nil || !changed { @@ -357,14 +483,44 @@ func (f *Files) read(name string) (int, error) { return f.takeIn(name, data, false) } -// readChanged returns what the state file name holds, and whether that -// has changed since sww last read or wrote the file, as it has -// for a file sww never read or wrote. A missing file has not -// changed: it is written again at its next write. -func (f *Files) readChanged(name string) ([]byte, bool, error) { - path := filepath.Join(f.params.Dir, name) +// readCache takes in the cache file name at start, as read does. A cache +// file that cannot be read, or does not parse, does not stop the start: +// it is logged, and its cache starts empty and fills again. One that does +// not parse is set aside as an edit is, and one that cannot be read is +// written over at its next write. +func (f *Files) readCache(name string) int { + data, changed, err := f.readChanged(name) + if err != nil || !changed { + f.logUnread(name, err) - data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR + return 0 + } + + entries, err := f.takeIn(name, data, false) + if err != nil { + f.logUnread(name, f.setAside(name, err)) + } + + return entries +} + +// logUnread logs err, the reason the cache file name was not read at +// start, unless it is nil. +func (f *Files) logUnread(name string, err error) { + if err != nil { + f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty", + "file", filepath.Join(f.dir(name), name), "error", err.Error()) + } +} + +// readChanged returns what the file name holds, and whether that has +// changed since sww last read or wrote the file, as it has for a file +// sww never read or wrote. A missing file has not changed: it is written +// again at its next write. +func (f *Files) readChanged(name string) ([]byte, bool, error) { + path := filepath.Join(f.dir(name), name) + + data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } @@ -376,15 +532,39 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) { return data, sha256.Sum256(data) != f.sums[name], nil } -// takeIn parses data, what the state file name holds, puts it into the +// takeIn parses data, what the file name holds, puts it into the // part that keeps that state, in place of what the part held, and returns // how many entries the file holds. edit is whether data is an admin's // edit taken in while sww runs, rather than the file read at the // start. An error names the file and, where the JSON decoder tells it, -// the line and column, or else the entry. +// the line and column, or else the entry. abuseipdb-checks-spent.json +// holds no entries. func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { - path := filepath.Join(f.params.Dir, name) + path := filepath.Join(f.dir(name), name) + var ( + entries int + err error + ) + + if isCacheFile(name) { + entries, err = f.takeInCache(name, path, data) + } else { + entries, err = f.takeInState(name, path, data, edit) + } + + if err != nil { + return 0, err + } + + f.sums[name] = sha256.Sum256(data) + + return entries, nil +} + +// takeInState parses data, what the state file name, at path, holds, and +// puts it in, as takeIn describes. +func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) { var entries int switch name { @@ -418,6 +598,33 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { f.params.Limiter.Load(file.Clients, f.params.Now()) entries = len(file.Clients) + case abuseIPDBChecksSpentJSON: + var file abuseIPDBChecksSpentFile + + err := parse(path, data, &file) + if err != nil { + return 0, err + } + + f.params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{ + Day: file.Day, Spent: file.Spent, + }) + case alertsJSON: + waiting, err := f.takeInAlerts(path, data) + if err != nil { + return 0, err + } + + entries = waiting + } + + return entries, nil +} + +// takeInCache parses data, what the cache file name, at path, holds, and +// puts it in, as takeIn describes. +func (f *Files) takeInCache(name, path string, data []byte) (int, error) { + switch name { case lookupsJSON: var file lookupsFile @@ -427,9 +634,10 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { } f.params.GeoJS.Load(file.Lookups) - entries = len(file.Lookups) - case reputationJSON: - var file reputationFile + + return len(file.Lookups), nil + case listsJSON: + var file listsFile err := parse(path, data, &file) if err != nil { @@ -441,21 +649,43 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) { return 0, fmt.Errorf("%s: %w", path, err) } - f.params.DNSBL.Load(file.Verdicts) - f.params.AbuseIPDB.Load(file.AbuseIPDB) - entries = len(file.Lists) - case alertsJSON: - waiting, err := f.takeInAlerts(path, data) + return len(file.Lists), nil + case dnsblJSON: + var file dnsblFile + + err := parse(path, data, &file) if err != nil { return 0, err } - entries = waiting + f.params.DNSBL.Load(file.Verdicts) + + return len(file.Verdicts), nil + case routeWatchJSON: + var file routeWatchFile + + err := parse(path, data, &file) + if err != nil { + return 0, err + } + + if f.params.RouteWatch != nil { + f.params.RouteWatch.Load(file.Answers) + } + + return len(file.Answers), nil + default: // abuseipdb-scores.json + var file abuseIPDBScoresFile + + err := parse(path, data, &file) + if err != nil { + return 0, err + } + + f.params.AbuseIPDB.Load(file.Scores) + + return len(file.Scores), nil } - - f.sums[name] = sha256.Sum256(data) - - return entries, nil } // takeInAlerts parses data, what alerts.json, at path, holds, puts it @@ -493,17 +723,22 @@ func (f *Files) takeInAlerts(path string, data []byte) (int, error) { return entries, nil } -// writeFile writes the state file name from what sww holds. An -// edit made since sww last read or wrote the file is taken in -// first, so that it is not overwritten, or set aside if it does not -// parse. A file that cannot be read, or an edit that cannot be set -// aside, is left as it is, and the write given up. Every write is counted -// in the metrics, and one that fails or is given up as a failure. +// writeFile writes the file name from what sww holds. An edit made since +// sww last read or wrote the file is taken in first, so that it is not +// overwritten, or set aside if it does not parse. A state file that +// cannot be read, or an edit that cannot be set aside, is left as it is, +// and the write given up. A cache file that cannot be read is written +// over, since what it held is fetched again. Every write is counted in +// the metrics, and one that fails or is given up as a failure. func (f *Files) writeFile(name string) error { f.mu.Lock() defer f.mu.Unlock() data, changed, err := f.readChanged(name) + if err != nil && isCacheFile(name) { + err = nil + } + if err == nil && changed { err = f.takeInEdit(name, data) if err != nil { @@ -519,7 +754,7 @@ func (f *Files) writeFile(name string) error { } if err == nil { - err = write(f.params.Dir, name, data) + err = write(f.dir(name), name, data) } if err == nil { @@ -527,7 +762,7 @@ func (f *Files) writeFile(name string) error { // fails, so that its next read does not take it for an admin's // edit. f.sums[name] = sha256.Sum256(data) - err = syncDirectory(f.params.Dir) + err = syncDirectory(f.dir(name)) } f.params.Metrics.StateFileWritten(name, len(data), err) @@ -535,20 +770,23 @@ func (f *Files) writeFile(name string) error { return err } -// setAside renames the state file name, an edit that does not parse with +// setAside renames the file name, an edit that does not parse with // parseErr, to name.bad, for the admin to mend, and logs it with where in // the file the error is, and raises a file_error alert for it. If the // rename fails, the edit is left as it is, and the error returned is // parseErr joined with the rename's. func (f *Files) setAside(name string, parseErr error) error { - path := filepath.Join(f.params.Dir, name) + path := filepath.Join(f.dir(name), name) err := os.Rename(path, path+".bad") if err != nil { return errors.Join(parseErr, err) } - const setAside = "set aside an edit of a state file that does not parse" + setAside := "set aside an edit of a state file that does not parse" + if isCacheFile(name) { + setAside = "set aside an edit of a cache file that does not parse" + } // Raised before it is logged, so that the alert is there once the log // line is. @@ -563,7 +801,7 @@ func (f *Files) setAside(name string, parseErr error) error { return nil } -// encode returns the state file name as sww writes it, from a +// encode returns the file name as sww writes it, from a // snapshot of the part that keeps that state. func (f *Files) encode(name string) ([]byte, error) { switch name { @@ -575,10 +813,24 @@ func (f *Files) encode(name string) ([]byte, error) { return encodeOnePerLine("clients", f.params.Limiter.Snapshot()) case lookupsJSON: return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot()) - case reputationJSON: - return encodeIndented(reputationFile{ - Version: version, Lists: f.params.Lists.Snapshot(), - Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(), + case listsJSON: + return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()}) + case dnsblJSON: + return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot()) + case abuseIPDBScoresJSON: + return encodeOnePerLine("scores", f.params.AbuseIPDB.Snapshot()) + case routeWatchJSON: + var answers []routewatch.Answer + if f.params.RouteWatch != nil { + answers = f.params.RouteWatch.Snapshot() + } + + return encodeOnePerLine("answers", answers) + case abuseIPDBChecksSpentJSON: + checks := f.params.AbuseIPDB.ChecksSpent() + + return encodeIndented(abuseIPDBChecksSpentFile{ + Version: version, Day: checks.Day, Spent: checks.Spent, }) default: // alerts.json held := f.params.Alerts.Snapshot() @@ -748,14 +1000,8 @@ func (f *lookupsFile) check(data []byte) error { // check refuses a list without its URL, which would name no list, or the // time it was last tried, which would have it fetched at once, and a copy // of it without the time it was fetched, or without its lines, which hold -// the list. It refuses a verdict without its zone or its client, which -// would be about no one, whether the zone lists the client, or the time -// it was fetched, which would drop it, and so an AbuseIPDB score without -// its client, the score, or the time it was fetched. A verdict's listed is -// false for a client the zone does not list, and a score can be 0, which -// the structs cannot tell from a missing one, so each is read again as -// written. -func (f *reputationFile) check(data []byte) error { +// the list. +func (f *listsFile) check([]byte) error { for i, kept := range f.Lists { switch { case kept.URL == "": @@ -769,6 +1015,15 @@ func (f *reputationFile) check(data []byte) error { } } + return nil +} + +// check refuses a verdict without its zone or its client, which would be +// about no one, whether the zone lists the client, or the time it was +// fetched, which would drop it. A verdict's listed is false for a client +// the zone does not list, which Verdict cannot tell from a missing one, +// so each listed is read again as written. +func (f *dnsblFile) check(data []byte) error { var written struct { Verdicts []struct { Listed *bool `json:"listed"` @@ -783,28 +1038,27 @@ func (f *reputationFile) check(data []byte) error { for i, verdict := range f.Verdicts { switch { case verdict.Zone == "": - return fmt.Errorf("verdicts %w", missing(i, "zone")) + return missing(i, "zone") case !verdict.Client.IsValid(): - return fmt.Errorf("verdicts %w", missing(i, "client")) + return missing(i, "client") case written.Verdicts[i].Listed == nil: - return fmt.Errorf("verdicts %w", missing(i, "listed")) + return missing(i, "listed") case verdict.Fetched.IsZero(): - return fmt.Errorf("verdicts %w", missing(i, "fetched")) + return missing(i, "fetched") } } - return checkScores(f.AbuseIPDB.Scores, data) + return nil } -// checkScores refuses an AbuseIPDB score, of scores, read from data, as -// reputationFile's check describes. -func checkScores(scores []reputation.Score, data []byte) error { +// check refuses an AbuseIPDB score without its client, the score, or the +// time it was fetched. A score can be 0, which Score cannot tell from a +// missing one, so each score is read again as written. +func (f *abuseIPDBScoresFile) check(data []byte) error { var written struct { - AbuseIPDB struct { - Scores []struct { - Score *int64 `json:"score"` - } `json:"scores"` - } `json:"abuseipdb"` + Scores []struct { + Score *int64 `json:"score"` + } `json:"scores"` } err := json.Unmarshal(data, &written) @@ -812,20 +1066,57 @@ func checkScores(scores []reputation.Score, data []byte) error { return err } - for i, kept := range scores { + for i, kept := range f.Scores { switch { case !kept.Client.IsValid(): - return fmt.Errorf("abuseipdb scores %w", missing(i, "client")) - case written.AbuseIPDB.Scores[i].Score == nil: - return fmt.Errorf("abuseipdb scores %w", missing(i, "score")) + return missing(i, "client") + case written.Scores[i].Score == nil: + return missing(i, "score") case kept.Fetched.IsZero(): - return fmt.Errorf("abuseipdb scores %w", missing(i, "fetched")) + return missing(i, "fetched") } } return nil } +// check refuses an answer without its client, which would answer for +// nobody, its netblock, or the time routewatch gave it, which would drop +// it. "" is the netblock of an address without a live route, which Answer +// cannot tell from a missing one, so each netblock is read again as +// written. +func (f *routeWatchFile) check(data []byte) error { + var written struct { + Answers []struct { + Netblock *string `json:"netblock"` + } `json:"answers"` + } + + err := json.Unmarshal(data, &written) + if err != nil { + return err + } + + for i, answer := range f.Answers { + switch { + case !answer.Client.IsValid(): + return missing(i, "client") + case written.Answers[i].Netblock == nil: + return missing(i, "netblock") + case answer.Answered.IsZero(): + return missing(i, "answered") + } + } + + return nil +} + +// check refuses nothing, since a missing day or count means no check +// spent. +func (f *abuseIPDBChecksSpentFile) check([]byte) error { + return nil +} + // check refuses a cooldown without its event or when its alert was sent, // which would hold back no repeat, alerts waiting for a destination with // another name than webhook, slack or ntfy, most likely misspelt, an @@ -947,22 +1238,37 @@ func (f *lookupsFile) toUTC() { } } -// toUTC converts every time of each list, verdict and AbuseIPDB score to -// UTC. -func (f *reputationFile) toUTC() { +// toUTC converts both times of each list to UTC. +func (f *listsFile) toUTC() { for i := range f.Lists { convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched) } +} +// toUTC converts the time of each verdict to UTC. +func (f *dnsblFile) toUTC() { for i := range f.Verdicts { convertToUTC(&f.Verdicts[i].Fetched) } +} - for i := range f.AbuseIPDB.Scores { - convertToUTC(&f.AbuseIPDB.Scores[i].Fetched) +// toUTC converts the time of each AbuseIPDB score to UTC. +func (f *abuseIPDBScoresFile) toUTC() { + for i := range f.Scores { + convertToUTC(&f.Scores[i].Fetched) } } +// toUTC converts the time of each answer to UTC. +func (f *routeWatchFile) toUTC() { + for i := range f.Answers { + convertToUTC(&f.Answers[i].Answered) + } +} + +// toUTC does nothing: abuseipdb-checks-spent.json holds a day, not a time. +func (f *abuseIPDBChecksSpentFile) toUTC() {} + // toUTC converts every time of each cooldown, of the hour, of each alert // waiting and of each anomaly counter to UTC. func (f *alertsFile) toUTC() { @@ -1114,7 +1420,7 @@ func write(dir, name string, data []byte) error { // syncDirectory syncs dir to the disk, so that a rename in it lasts. func syncDirectory(dir string) error { - directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself + directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR or SWW_CACHE_DIR itself if err != nil { return err } @@ -1124,7 +1430,7 @@ func syncDirectory(dir string) error { // writeSynced writes data to the file at path, and syncs it to the disk. func writeSynced(path string, data []byte) error { - //nolint:gosec // a state file's temporary file, in SWW_STATE_DIR + //nolint:gosec // a file's temporary file, in SWW_STATE_DIR or SWW_CACHE_DIR file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { return err diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 036e517..83971b8 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -30,16 +30,30 @@ import ( "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" + "sneak.berlin/go/smallwebwaf/internal/routewatch" "sneak.berlin/go/smallwebwaf/internal/state" ) const ( // The state files. - bansJSON = "bans.json" - clientsJSON = "clients.json" - lookupsJSON = "lookups.json" - reputationJSON = "reputation.json" - alertsJSON = "alerts.json" + bansJSON = "bans.json" + clientsJSON = "clients.json" + abuseIPDBChecksSpentJSON = "abuseipdb-checks-spent.json" + alertsJSON = "alerts.json" + // The cache files. + lookupsJSON = "lookups.json" + listsJSON = "lists.json" + dnsblJSON = "dnsbl.json" + abuseIPDBScoresJSON = "abuseipdb-scores.json" + routeWatchJSON = "routewatch.json" + // What the process log says as a state file, and a cache file, that does + // not parse is set aside. + setAside = "set aside an edit of a state file that does not parse" + cacheSetAside = "set aside an edit of a cache file that does not parse" + // What Load says, after a file's path, of a first entry without its + // client or the time it was fetched. + noClient = `: entry 1 has no "client"` + noFetched = `: entry 1 has no "fetched"` // blocklistURL and torURL are the blocklists the tests' lists name, and // dnsblZone the DNSBL zone of the tests' verdicts. blocklistURL = "https://lists.example/drop.txt" @@ -48,10 +62,11 @@ const ( // 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 - // it takes in an edit. - watching = "watching the state files for edits" - tookIn = "took in an edit of a state file" + // What the process log says once Watch watches both directories, and as + // it takes in an edit of a state file, and of a cache file. + watching = "watching the state and cache files for edits" + tookIn = "took in an edit of a state file" + cacheTookIn = "took in an edit of a cache file" // maxLogLines is how many lines of the process log wait for a test to // read them. maxLogLines = 64 @@ -223,11 +238,9 @@ const filledAlertsJSON = `{ } ` -// filledReputationJSON is reputation.json holding the blocklists' last -// tries and the copy of one, with its comment line, two verdicts of a -// DNSBL zone, and the AbuseIPDB checks spent today with two scores, as -// fill puts them in. -const filledReputationJSON = `{ +// filledListsJSON is lists.json holding the blocklists' last tries and +// the copy of one, with its comment line, as fill puts them in. +const filledListsJSON = `{ "version": 1, "lists": [ { @@ -244,40 +257,65 @@ const filledReputationJSON = `{ "url": "https://lists.example/tor.txt", "tried": "2026-10-06T00:00:00Z" } - ], - "verdicts": [ - { - "zone": "dnsbl.example", - "client": "203.0.113.9", - "listed": true, - "fetched": "2026-10-05T23:00:00Z" - }, - { - "zone": "dnsbl.example", - "client": "2001:db8::1", - "listed": false, - "fetched": "2026-10-05T22:00:00Z" - } - ], - "abuseipdb": { - "day": "2026-10-06", - "spent": 3, - "scores": [ - { - "client": "203.0.113.9/32", - "score": 100, - "fetched": "2026-10-05T23:00:00Z" - }, - { - "client": "2001:db8::/64", - "score": 0, - "fetched": "2026-10-05T22:00:00Z" - } - ] - } + ] } ` +// filledDNSBLJSON is dnsbl.json holding the two verdicts of a DNSBL zone +// fill puts in. +const filledDNSBLJSON = `{ + "version": 1, + "verdicts": [ + {"zone":"dnsbl.example","client":"203.0.113.9","listed":true,` + + `"fetched":"2026-10-05T23:00:00Z"}, + {"zone":"dnsbl.example","client":"2001:db8::1","listed":false,` + + `"fetched":"2026-10-05T22:00:00Z"} + ] +} +` + +// filledAbuseIPDBScoresJSON is abuseipdb-scores.json holding the two +// AbuseIPDB scores fill puts in. +const filledAbuseIPDBScoresJSON = `{ + "version": 1, + "scores": [ + {"client":"203.0.113.9/32","score":100,"fetched":"2026-10-05T23:00:00Z"}, + {"client":"2001:db8::/64","score":0,"fetched":"2026-10-05T22:00:00Z"} + ] +} +` + +// filledAbuseIPDBChecksSpentJSON is abuseipdb-checks-spent.json holding +// the AbuseIPDB checks spent today, as fill puts them in. +const filledAbuseIPDBChecksSpentJSON = `{ + "version": 1, + "day": "2026-10-06", + "spent": 3 +} +` + +// filledRouteWatchJSON is routewatch.json holding the two answers of +// routewatch fill puts in: a route, and an address without a live route. +const filledRouteWatchJSON = `{ + "version": 1, + "answers": [ + {"client":"203.0.113.9/32","netblock":"203.0.113.0/24","asn":64496,` + + `"answered":"2026-10-05T23:59:00Z"}, + {"client":"2001:db8::/64","netblock":"","asn":0,` + + `"answered":"2026-10-05T23:58:00Z"} + ] +} +` + +// everyFile returns the names of the state and cache files, sorted, as +// wantFiles lists them in the one directory newParams gives both. +func everyFile() []string { + return []string{ + abuseIPDBChecksSpentJSON, abuseIPDBScoresJSON, alertsJSON, bansJSON, clientsJSON, + dnsblJSON, listsJSON, lookupsJSON, routeWatchJSON, + } +} + func TestFilesWrittenAndReadBack(t *testing.T) { t.Parallel() @@ -306,14 +344,19 @@ func TestFilesWrittenAndReadBack(t *testing.T) { if got, want := after.Lists.Snapshot(), before.Lists.Snapshot(); !reflect.DeepEqual( got, want) { - t.Errorf("%s read back\n%+v\nwant\n%+v", reputationJSON, got, want) + t.Errorf("%s read back\n%+v\nwant\n%+v", listsJSON, got, want) } - wantEqual(t, reputationJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot()) + wantEqual(t, dnsblJSON, after.DNSBL.Snapshot(), before.DNSBL.Snapshot()) + wantEqual(t, abuseIPDBScoresJSON, after.AbuseIPDB.Snapshot(), + before.AbuseIPDB.Snapshot()) + wantEqual(t, routeWatchJSON, after.RouteWatch.Snapshot(), + before.RouteWatch.Snapshot()) - checks, wantChecks := after.AbuseIPDB.Snapshot(), before.AbuseIPDB.Snapshot() - if !reflect.DeepEqual(checks, wantChecks) { - t.Errorf("%s read back\n%+v\nwant\n%+v", reputationJSON, checks, wantChecks) + checks, wantChecks := after.AbuseIPDB.ChecksSpent(), before.AbuseIPDB.ChecksSpent() + if checks != wantChecks { + t.Errorf("%s read back %+v, want %+v", abuseIPDBChecksSpentJSON, checks, + wantChecks) } if got, want := after.Alerts.Snapshot(), before.Alerts.Snapshot(); !reflect.DeepEqual( @@ -324,15 +367,47 @@ func TestFilesWrittenAndReadBack(t *testing.T) { wantEqual(t, alertsJSON, after.Anomalies.Snapshot(), before.Anomalies.Snapshot()) // Each one-per-line file lists its entries by client, and nothing - // but the five files is left in the directory. + // but the nine files is left in the directory. wantEntries(t, filepath.Join(dir, clientsJSON), "clients", "192.0.2.1/32", "203.0.113.9/32", "2001:db8::/64") wantEntries(t, filepath.Join(dir, lookupsJSON), "lookups", "192.0.2.1/32", "203.0.113.9/32") - wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) + wantFiles(t, dir, everyFile()...) } -func TestReputationJSONKeepsEachCopyWholeOneLineToALine(t *testing.T) { +func TestStateFilesInDirAndCacheFilesInCacheDir(t *testing.T) { + t.Parallel() + + stateDir, cacheDir := t.TempDir(), t.TempDir() + before := newParams(stateDir) + before.CacheDir = cacheDir + fill(before) + + err := load(t, before).WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + wantFiles(t, stateDir, abuseIPDBChecksSpentJSON, alertsJSON, bansJSON, clientsJSON) + wantFiles(t, cacheDir, abuseIPDBScoresJSON, dnsblJSON, listsJSON, lookupsJSON, + routeWatchJSON) + + // Read back from both, as at the next start. + after := newParams(stateDir) + after.CacheDir = cacheDir + load(t, after) + + wantEqual(t, bansJSON, after.Ledger.Snapshot(), before.Ledger.Snapshot()) + wantEqual(t, lookupsJSON, after.GeoJS.Snapshot(), before.GeoJS.Snapshot()) + + checks, wantChecks := after.AbuseIPDB.ChecksSpent(), before.AbuseIPDB.ChecksSpent() + if checks != wantChecks { + t.Errorf("%s read back %+v, want %+v", abuseIPDBChecksSpentJSON, checks, + wantChecks) + } +} + +func TestReputationFilesAsWritten(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -346,12 +421,54 @@ func TestReputationJSONKeepsEachCopyWholeOneLineToALine(t *testing.T) { t.Fatalf("write: %v", err) } - got := readFile(t, filepath.Join(dir, reputationJSON)) - if got != filledReputationJSON { - t.Errorf("reputation.json\n%s\nwant\n%s", got, filledReputationJSON) + // lists.json keeps each copy whole, one line to a line. + for name, want := range map[string]string{ + listsJSON: filledListsJSON, + dnsblJSON: filledDNSBLJSON, + abuseIPDBScoresJSON: filledAbuseIPDBScoresJSON, + abuseIPDBChecksSpentJSON: filledAbuseIPDBChecksSpentJSON, + } { + if got := readFile(t, filepath.Join(dir, name)); got != want { + t.Errorf("%s\n%s\nwant\n%s", name, got, want) + } } } +func TestRouteWatchJSONAsWritten(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + params := newParams(dir) + fill(params) + + err := load(t, params).WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + got := readFile(t, filepath.Join(dir, routeWatchJSON)) + if got != filledRouteWatchJSON { + t.Errorf("%s\n%s\nwant\n%s", routeWatchJSON, got, filledRouteWatchJSON) + } +} + +func TestRouteWatchJSONHoldsNoAnswersWhileRoutewatchIsUnset(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + edit(t, dir, routeWatchJSON, filledRouteWatchJSON) + + params := newParams(dir) + params.RouteWatch = nil + + err := load(t, params).WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + wantEntries(t, filepath.Join(dir, routeWatchJSON), "answers") +} + func TestAlertsJSONIsIndentedWithTheCooldownsTheHourAndTheAlertsWaiting(t *testing.T) { t.Parallel() @@ -473,11 +590,12 @@ func TestMissingFilesAreEmptyState(t *testing.T) { load(t, params) held := params.Alerts.Snapshot() - checks := params.AbuseIPDB.Snapshot() if len(params.Ledger.Snapshot()) != 0 || len(params.Limiter.Snapshot()) != 0 || len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 || - len(params.DNSBL.Snapshot()) != 0 || len(checks.Scores) != 0 || checks.Spent != 0 || + len(params.DNSBL.Snapshot()) != 0 || len(params.AbuseIPDB.Snapshot()) != 0 || + len(params.RouteWatch.Snapshot()) != 0 || + params.AbuseIPDB.ChecksSpent().Spent != 0 || len(held.Cooldowns) != 0 || len(held.Waiting[alerts.DestinationWebhook]) != 0 || held.Hour.Sent != 0 { t.Error("state from no files") @@ -507,14 +625,14 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) { }, { // Found at the newline that ends the file. - "a cut-off file", lookupsJSON, - "{\n \"version\": 1,\n \"lookups\": [\n", - ", line 3, column 17: unexpected end of JSON input", + "a cut-off file", alertsJSON, + "{\n \"version\": 1,\n \"cooldowns\": [\n", + ", line 3, column 19: unexpected end of JSON input", }, { - "an unknown field", lookupsJSON, - `{"version": 1, "lookups": [{"client": "203.0.113.9/32", "contry": "DE"}]}`, - `: json: unknown field "contry"`, + "an unknown field", abuseIPDBChecksSpentJSON, + `{"version": 1, "day": "2026-10-06", "spnet": 3}`, + `: json: unknown field "spnet"`, }, { "a netblock that does not read", bansJSON, @@ -538,14 +656,6 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) { `: anomaly_counters entry 2's scope "nett" is not client, net, asn, total ` + `or watched_net`, }, - { - "a copy of a list with a line that does not read", reputationJSON, - `{"version": 1, "lists": [{"url": "` + blocklistURL + `", ` + - `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", ` + - `"lines": ["; DROP", "203.0.113.300"]}]}`, - `: the copy of ` + blocklistURL + `: line 2 is not an address or a netblock, ` + - `such as 192.0.2.0/24`, - }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -555,13 +665,110 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) { } } +func TestCacheFileThatDoesNotParseIsSetAsideAndTheCacheStartsEmpty(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, file, content string + // want is what the error says after the file's path. + want string + }{ + { + // Found at the newline that ends the file. + "a cut-off file", lookupsJSON, + "{\n \"version\": 1,\n \"lookups\": [\n", + ", line 3, column 17: unexpected end of JSON input", + }, + { + "an unknown field", lookupsJSON, + `{"version": 1, "lookups": [{"client": "203.0.113.9/32", "contry": "DE"}]}`, + `: json: unknown field "contry"`, + }, + { + "a copy of a list with a line that does not read", listsJSON, + `{"version": 1, "lists": [{"url": "` + blocklistURL + `", ` + + `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", ` + + `"lines": ["; DROP", "203.0.113.300"]}]}`, + `: the copy of ` + blocklistURL + `: line 2 is not an address or a netblock, ` + + `such as 192.0.2.0/24`, + }, + { + "a verdict of the wrong kind", dnsblJSON, + `{"version": 1, "verdicts": [{"zone": "` + dnsblZone + `", "listed": "yes"}]}`, + `, line 1, column `, + }, + { + "an unknown version", abuseIPDBScoresJSON, `{"version": 2}`, + ": unknown version ", + }, + { + "an answer without its client", routeWatchJSON, + `{"version": 1, "answers": [{"netblock": "", ` + + `"answered": "2026-10-06T00:00:00Z"}]}`, + noClient, + }, + { + // A netblock of "" is an address without a live route. + "an answer without its netblock", routeWatchJSON, + `{"version": 1, "answers": [{"client": "192.0.2.1/32", "netblock": "", ` + + `"answered": "2026-10-06T00:00:00Z"}, {"client": "203.0.113.9/32", ` + + `"answered": "2026-10-06T00:00:00Z"}]}`, + `: entry 2 has no "netblock"`, + }, + { + "an answer without the time routewatch gave it", routeWatchJSON, + `{"version": 1, "answers": [{"client": "203.0.113.9/32", "netblock": ""}]}`, + `: entry 1 has no "answered"`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + wantSetAside(t, tc.file, tc.content, tc.want) + }) + } +} + +func TestCacheFileThatCannotBeReadIsWrittenOver(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, lookupsJSON) + + // lookups.json is a socket, which cannot be opened as a file, even by + // root, but which a rename can replace. + socket, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + + defer func() { + _ = socket.Close() + }() + + params := newParams(dir) + lines := logInto(¶ms) + files := load(t, params) + + line := lines.waitFor(t, "cannot read a cache file, so its cache starts empty") + if line["file"] != path { + t.Errorf("logged %v, want the file %s", line, path) + } + + err = files.WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + wantEntries(t, path, "lookups") +} + func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { t.Parallel() const ( - // The other fields each entry needs. - ban = `"start": "2026-10-06T00:00:00Z", "expires": null` - answer = `"answered": "2026-10-06T00:00:00Z"` + // The other fields each ban needs. + ban = `"start": "2026-10-06T00:00:00Z", "expires": null` noNetblock = `: entry 1 has no "netblock"` ) @@ -601,7 +808,7 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { { "a client without its address", clientsJSON, `{"version": 1, "clients": [{"history": {"requests": 3}}]}`, - `: entry 1 has no "client"`, + noClient, }, { "a client with requests in a window without its start", clientsJSON, @@ -615,20 +822,40 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { `"minute_bytes": {"previous": 5120}}]}`, `: entry 1 has no "minute_bytes.start"`, }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + wantRefused(t, tc.file, tc.content, tc.want) + }) + } +} + +func TestLookupsJSONEntryWithoutAFieldItNeedsIsSetAside(t *testing.T) { + t.Parallel() + + // answer is the other field each answer needs. + const answer = `"answered": "2026-10-06T00:00:00Z"` + + for _, tc := range []struct { + name, content string + // want is what the error says after the file's path. + want string + }{ { - "an answer without a client", lookupsJSON, + "an answer without a client", `{"version": 1, "lookups": [{"country": "DE", ` + answer + `}]}`, - `: entry 1 has no "client"`, + noClient, }, { // A country of "" is a client GeoJS cannot place. - "an answer without a country", lookupsJSON, + "an answer without a country", `{"version": 1, "lookups": [{"client": "192.0.2.1/32", "country": "", ` + answer + `}, {"client": "203.0.113.9/32", ` + answer + `}]}`, `: entry 2 has no "country"`, }, { - "an answer without the time GeoJS gave it", lookupsJSON, + "an answer without the time GeoJS gave it", `{"version": 1, "lookups": [{"client": "203.0.113.9/32", "country": "DE"}]}`, `: entry 1 has no "answered"`, }, @@ -636,7 +863,7 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - wantRefused(t, tc.file, tc.content, tc.want) + wantSetAside(t, lookupsJSON, tc.content, tc.want) }) } } @@ -671,7 +898,7 @@ func TestClientsJSONFromAnEarlierVersionWithTokenRefusalsLoads(t *testing.T) { } } -func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { +func TestListsAndDNSBLJSONEntryWithoutAFieldItNeedsIsSetAside(t *testing.T) { t.Parallel() const ( @@ -686,76 +913,76 @@ func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { ) for _, tc := range []struct { - name, content string + name, file, content string // want is what the error says after the file's path. want string }{ { - "a list without its URL", + "a list without its URL", listsJSON, `{"version": 1, "lists": [{` + tried + fetched + `, "lines": []}]}`, `: entry 1 has no "url"`, }, { - "a list without the time it was last tried", + "a list without the time it was last tried", listsJSON, `{"version": 1, "lists": [{` + drop + fetched + `, "lines": []}]}`, `: entry 1 has no "tried"`, }, { - "a copy of a list without the time it was fetched", + "a copy of a list without the time it was fetched", listsJSON, `{"version": 1, "lists": [{` + drop + tried + `"lines": []}]}`, - `: entry 1 has no "fetched"`, + noFetched, }, { // An empty list has no lines, which is not having none. - "a copy of a list without its lines", + "a copy of a list without its lines", listsJSON, `{"version": 1, "lists": [{` + drop + tried + fetched + `, "lines": []}, ` + `{"url": "` + torURL + `", ` + tried + fetched + `}]}`, `: entry 2 has no "lines"`, }, { - "a verdict without its zone", + "a verdict without its zone", dnsblJSON, `{"version": 1, "verdicts": [{` + verdictClient + listed + fetched + `}]}`, - `: verdicts entry 1 has no "zone"`, + `: entry 1 has no "zone"`, }, { - "a verdict without its client", + "a verdict without its client", dnsblJSON, `{"version": 1, "verdicts": [{` + verdictZone + listed + fetched + `}]}`, - `: verdicts entry 1 has no "client"`, + noClient, }, { // A client the zone does not list has a listed of false, which is // not having none. - "a verdict without whether the zone lists the client", + "a verdict without whether the zone lists the client", dnsblJSON, `{"version": 1, "verdicts": [{` + verdictZone + verdictClient + listed + fetched + `}, {` + verdictZone + verdictClient + fetched + `}]}`, - `: verdicts entry 2 has no "listed"`, + `: entry 2 has no "listed"`, }, { - "a verdict without the time it was fetched", + "a verdict without the time it was fetched", dnsblJSON, `{"version": 1, "verdicts": [{` + verdictZone + verdictClient + `"listed": true}]}`, - `: verdicts entry 1 has no "fetched"`, + noFetched, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - wantRefused(t, reputationJSON, tc.content, tc.want) + wantSetAside(t, tc.file, tc.content, tc.want) }) } } -func TestReputationJSONScoreWithoutAFieldItNeedsStopsTheStart(t *testing.T) { +func TestAbuseIPDBScoresJSONScoreWithoutAFieldItNeedsIsSetAside(t *testing.T) { t.Parallel() // scores opens the list of AbuseIPDB scores, and ends closes it; client, // score and fetched make a score. const ( - scores = `{"version": 1, "abuseipdb": {"scores": [` + scores = `{"version": 1, "scores": [` client = `"client": "198.51.100.7/32", ` score = `"score": 0, ` fetched = `"fetched": "2026-10-06T00:00:00Z"` - ends = `}]}}` + ends = `}]}` ) for _, tc := range []struct { @@ -765,23 +992,23 @@ func TestReputationJSONScoreWithoutAFieldItNeedsStopsTheStart(t *testing.T) { }{ { "without its client", scores + `{` + score + fetched + ends, - `: abuseipdb scores entry 1 has no "client"`, + noClient, }, { // A score of 0 is not having none. "without the score", scores + `{` + client + score + fetched + `}, {` + client + fetched + ends, - `: abuseipdb scores entry 2 has no "score"`, + `: entry 2 has no "score"`, }, { "without the time it was fetched", scores + `{` + client + `"score": 100` + ends, - `: abuseipdb scores entry 1 has no "fetched"`, + noFetched, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - wantRefused(t, reputationJSON, tc.content, tc.want) + wantSetAside(t, abuseIPDBScoresJSON, tc.content, tc.want) }) } } @@ -907,7 +1134,7 @@ func TestUnknownVersionStopsTheStart(t *testing.T) { t.Parallel() for _, file := range []string{ - bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON, + bansJSON, clientsJSON, abuseIPDBChecksSpentJSON, alertsJSON, } { for _, content := range []string{`{"version": 2}`, `{}`} { t.Run(file+" "+content, func(t *testing.T) { @@ -939,6 +1166,17 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) { if err == nil || !strings.HasPrefix(err.Error(), want) { t.Errorf("state directory %s: error %v, want one starting %s", dir, err, want) } + + const wantCache = "SWW_CACHE_DIR cannot be written: " + + params := newParams(t.TempDir()) + params.CacheDir = dir + + _, err = state.Load(params) + if err == nil || !strings.HasPrefix(err.Error(), wantCache) { + t.Errorf("cache directory %s: error %v, want one starting %s", dir, err, + wantCache) + } } } @@ -1010,9 +1248,8 @@ func TestEveryFileWrittenEveryCounterInterval(t *testing.T) { time.Sleep(time.Nanosecond) synctest.Wait() - wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) - removeFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, - reputationJSON) + wantFiles(t, dir, everyFile()...) + removeFiles(t, dir, everyFile()...) } }) } @@ -1064,43 +1301,52 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(t *testi params.CounterInterval = time.Minute run(t, load(t, params).Run) - // A directory in the way of clients.json's temporary file fails each - // of its writes, while the other files are written. It holds a file, - // so that the write cannot remove it. - err := os.Mkdir(filepath.Join(dir, clientsJSON+".tmp"), 0o700) - if err == nil { - err = os.WriteFile(filepath.Join(dir, clientsJSON+".tmp", "kept"), nil, 0o600) + // A directory in the way of the temporary file of clients.json, a + // state file, and of lookups.json, a cache file, fails each of their + // writes, while the other files are written. Each holds a file, so + // that the write cannot remove it. + failing := []struct{ name, reason string }{ + {clientsJSON, "writing the state files failed"}, + {lookupsJSON, "writing the cache files failed"}, } - if err != nil { - t.Fatalf("put a directory in the way: %v", err) + for _, file := range failing { + err := os.Mkdir(filepath.Join(dir, file.name+".tmp"), 0o700) + if err == nil { + err = os.WriteFile(filepath.Join(dir, file.name+".tmp", "kept"), nil, 0o600) + } + + if err != nil { + t.Fatalf("put a directory in the way: %v", err) + } } time.Sleep(time.Minute) synctest.Wait() waiting := params.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != 1 { - t.Fatalf("%d alerts wait, want 1", len(waiting)) + if len(waiting) != len(failing) { + t.Fatalf("%d alerts wait, want %d", len(waiting), len(failing)) } - message, _ := waiting[0].Detail["error"].(string) + for i, file := range failing { + message, _ := waiting[i].Detail["error"].(string) - if waiting[0].Event != alerts.EventFileError || - waiting[0].Reason != "writing the state files failed" || - waiting[0].Detail["file"] != filepath.Join(dir, clientsJSON) || - !strings.Contains(message, clientsJSON+".tmp") { - t.Fatalf("alerts waiting %+v, want a file_error alert for clients.json, "+ - "naming its temporary file", waiting) + if waiting[i].Event != alerts.EventFileError || waiting[i].Reason != file.reason || + waiting[i].Detail["file"] != filepath.Join(dir, file.name) || + !strings.Contains(message, file.name+".tmp") { + t.Fatalf("alerts waiting %+v, want a file_error alert for %s, saying %q "+ + "and naming its temporary file", waiting, file.name, file.reason) + } } - // The next write fails too, within the cooldown, which holds it back. + // The next writes fail too, within the cooldown, which holds them back. time.Sleep(time.Minute) synctest.Wait() waiting = params.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] - if len(waiting) != 1 || params.Alerts.Suppressed() != 1 { - t.Errorf("%d alerts wait and %d are held back, want 1 and 1", + if len(waiting) != 2 || params.Alerts.Suppressed() != 2 { + t.Errorf("%d alerts wait and %d are held back, want 2 and 2", len(waiting), params.Alerts.Suppressed()) } }) @@ -1231,7 +1477,7 @@ func TestFileThatCannotBeReadIsNotWrittenOver(t *testing.T) { t.Errorf("bans.json is now %v (%v), want the socket", info, err) } - wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) + wantFiles(t, dir, everyFile()...) wantWriteFailed(t, params, bansJSON) } @@ -1304,32 +1550,15 @@ func TestEditOfEachFileTakenIn(t *testing.T) { wantEqual(t, lookupsJSON, params.GeoJS.Snapshot(), []lookup.Answer{{Client: client, Country: "FR", Answered: midnight()}}) - edit(t, dir, reputationJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+ - `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+ - `"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+ - `"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+ - `"abuseipdb": {"day": "2026-10-06", "spent": 9, "scores": [`+ - `{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`) - wantTakenIn(t, lines, dir, reputationJSON) - - listedBy := params.Lists.ListedBy(client.Addr()) - if !slices.Equal(listedBy, []string{blocklistURL}) { - t.Errorf("%s taken in lists %s on %v, want on the blocklist", reputationJSON, - client.Addr(), listedBy) - } - - wantEqual(t, reputationJSON, params.DNSBL.Snapshot(), []reputation.Verdict{{ - Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(), + edit(t, dir, routeWatchJSON, `{"version": 1, "answers": [`+ + `{"client": "198.51.100.7/32", "netblock": "198.51.100.0/24", "asn": 64497, `+ + `"answered": "2026-10-06T00:00:00Z"}]}`) + wantTakenIn(t, lines, dir, routeWatchJSON) + wantEqual(t, routeWatchJSON, params.RouteWatch.Snapshot(), []routewatch.Answer{{ + Client: client, Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497, + Answered: midnight(), }}) - checks := reputation.Checks{ - Day: civil.DateOf(midnight()), Spent: 9, - Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}}, - } - if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) { - t.Errorf("%s taken in as\n%+v\nwant\n%+v", reputationJSON, got, checks) - } - // A netblock with bits past its length is read as the netblock it is // in. edit(t, dir, alertsJSON, `{"version": 1, "cooldowns": [{"event": "ban", `+ @@ -1352,6 +1581,60 @@ func TestEditOfEachFileTakenIn(t *testing.T) { } } +func TestEditOfEachReputationFileTakenIn(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + params := newParams(dir) + lines := logInto(¶ms) + fill(params) + files := load(t, params) + + err := files.WriteAll() + if err != nil { + t.Fatalf("write: %v", err) + } + + watch(t, files, lines) + + // Each edit holds one entry, for a client the parts did not hold, and + // takes the place of everything the part held. + client := netip.MustParsePrefix("198.51.100.7/32") + + edit(t, dir, listsJSON, `{"version": 1, "lists": [{"url": "`+blocklistURL+`", `+ + `"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+ + `"lines": ["198.51.100.7"]}]}`) + wantTakenIn(t, lines, dir, listsJSON) + + listedBy := params.Lists.ListedBy(client.Addr()) + if !slices.Equal(listedBy, []string{blocklistURL}) { + t.Errorf("%s taken in lists %s on %v, want on the blocklist", listsJSON, + client.Addr(), listedBy) + } + + edit(t, dir, dnsblJSON, `{"version": 1, "verdicts": [{"zone": "`+dnsblZone+`", `+ + `"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}]}`) + wantTakenIn(t, lines, dir, dnsblJSON) + wantEqual(t, dnsblJSON, params.DNSBL.Snapshot(), []reputation.Verdict{{ + Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: midnight(), + }}) + + edit(t, dir, abuseIPDBScoresJSON, `{"version": 1, "scores": [`+ + `{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}`) + wantTakenIn(t, lines, dir, abuseIPDBScoresJSON) + wantEqual(t, abuseIPDBScoresJSON, params.AbuseIPDB.Snapshot(), + []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}}) + + edit(t, dir, abuseIPDBChecksSpentJSON, + `{"version": 1, "day": "2026-10-06", "spent": 9}`) + wantTakenIn(t, lines, dir, abuseIPDBChecksSpentJSON) + + checks := reputation.ChecksSpent{Day: civil.DateOf(midnight()), Spent: 9} + if got := params.AbuseIPDB.ChecksSpent(); got != checks { + t.Errorf("%s taken in as %+v, want %+v", abuseIPDBChecksSpentJSON, got, checks) + } +} + func TestTimesEditedWithAnOffsetWrittenBackInUTC(t *testing.T) { t.Parallel() @@ -1374,8 +1657,10 @@ func TestTimesEditedWithAnOffsetWrittenBackInUTC(t *testing.T) { ahead := time.FixedZone("UTC+2", 2*60*60) written := map[string]string{} + // abuseipdb-checks-spent.json holds a day, but no time. for _, name := range []string{ - bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON, + bansJSON, clientsJSON, alertsJSON, lookupsJSON, listsJSON, dnsblJSON, + abuseIPDBScoresJSON, routeWatchJSON, } { written[name] = readFile(t, filepath.Join(dir, name)) edited := stamp.ReplaceAllStringFunc(written[name], func(quoted string) string { @@ -1613,7 +1898,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { edit(t, dir, bansJSON, broken) edit(t, dir, clientsJSON, `{"version": 1, "clients": []}`) wantTakenIn(t, lines, dir, clientsJSON) - wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON) + wantFiles(t, dir, everyFile()...) // The next write sets it aside, logged with where the error is, and // writes bans.json again from what sww still holds. @@ -1622,7 +1907,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { t.Fatalf("write: %v", err) } - line := lines.waitFor(t, "set aside an edit of a state file that does not parse") + line := lines.waitFor(t, setAside) message, _ := line["error"].(string) if line["file"] != path+".bad" || @@ -1637,8 +1922,8 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) { t.Errorf("alerts waiting %+v, want a file_error alert for %s", waiting, path+".bad") } - wantFiles(t, dir, alertsJSON, bansJSON, bansJSON+".bad", clientsJSON, lookupsJSON, - reputationJSON) + wantFiles(t, dir, abuseIPDBChecksSpentJSON, abuseIPDBScoresJSON, alertsJSON, bansJSON, + bansJSON+".bad", clientsJSON, dnsblJSON, listsJSON, lookupsJSON, routeWatchJSON) if got := readFile(t, path+".bad"); got != broken { t.Errorf("bans.json.bad holds\n%s\nwant the edit", got) @@ -1761,16 +2046,80 @@ func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) { } } +func TestCacheFileEditedInACacheDirectoryApartTakenIn(t *testing.T) { + t.Parallel() + + stateDir, cacheDir := t.TempDir(), t.TempDir() + params := newParams(stateDir) + params.CacheDir = cacheDir + lines := logInto(¶ms) + watch(t, load(t, params), lines) + + edit(t, cacheDir, lookupsJSON, `{"version": 1, "lookups": []}`) + wantTakenIn(t, lines, cacheDir, lookupsJSON) +} + +func TestDirectoryThatCannotBeWatchedLeavesTheOtherWatched(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + // gone is the directory removed before Watch runs, and failed what + // the log says of it. watching is what the log says once Watch + // watches the other directory, and edited is a file in it. + gone, failed, watching, edited string + }{ + { + "state", "cannot watch the state files for edits", + "watching the cache files for edits", lookupsJSON, + }, + { + "cache", "cannot watch the cache files for edits", + "watching the state files for edits", bansJSON, + }, + } { + t.Run(tc.gone, func(t *testing.T) { + t.Parallel() + + stateDir, cacheDir := t.TempDir(), t.TempDir() + params := newParams(stateDir) + params.CacheDir = cacheDir + lines := logInto(¶ms) + files := load(t, params) + + gone, other := stateDir, cacheDir + if tc.gone == "cache" { + gone, other = cacheDir, stateDir + } + + err := os.Remove(gone) + if err != nil { + t.Fatalf("remove: %v", err) + } + + run(t, files.Watch) + + line := lines.waitFor(t, tc.failed) + if line["level"] != "ERROR" || line["directory"] != gone { + t.Errorf("logged %v, want an error naming %s", line, gone) + } + + lines.waitFor(t, tc.watching) + edit(t, other, tc.edited, `{"version": 1}`) + wantTakenIn(t, lines, other, tc.edited) + }) + } +} + // midnight is the time of the tests' clock. func midnight() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) } -// newParams returns Params for the state files in dir, with parts that -// hold nothing yet. GeoJS is never asked, the lists, two blocklists, are -// never fetched, and the alerts, at most two an hour, are -// never sent. The anomaly counters count the scopes fill counts, with -// thresholds fill does not reach. +// newParams returns Params for the state and cache files, both in dir, +// with parts that hold nothing yet. GeoJS and routewatch are never asked, +// the lists, two blocklists, are never fetched, and the alerts, at most two +// an hour, are never sent. The anomaly counters count the scopes fill +// counts, with thresholds fill does not reach. func newParams(dir string) state.Params { discard := slog.New(slog.DiscardHandler) m := metrics.New(1, "app") @@ -1785,6 +2134,7 @@ func newParams(dir string) state.Params { return state.Params{ Dir: dir, + CacheDir: dir, WriteDelay: time.Hour, CounterInterval: time.Hour, Ledger: bans.New(bans.Params{ @@ -1810,6 +2160,9 @@ func newParams(dir string) state.Params { MinScore: 75, DailyBudget: 900, CacheTTL: 24 * time.Hour, Timeout: time.Second, Now: midnight, ProcessLog: discard, Alerts: queue, }), + RouteWatch: routewatch.New(routewatch.Params{ + URL: "http://routewatch.example/", Now: midnight, ProcessLog: discard, + }), Alerts: queue, Anomalies: anomaly.New(anomaly.Params{ Net: anomaly.Thresholds{RequestsPerMinute: 1000}, @@ -1836,10 +2189,11 @@ func office() netip.Prefix { // for a clear sign of attack and one for CrowdSec's decision, clients // with counts and histories, // GeoJS answers, the blocklists' last tries and the copy of one, two -// verdicts of a DNSBL zone, and the AbuseIPDB checks spent today with two -// scores, as filledReputationJSON holds them, and alerts -// and anomaly counters, as filledAlertsJSON holds them, into the parts of -// params. +// verdicts of a DNSBL zone, the AbuseIPDB checks spent today with two +// scores, and two answers of routewatch, as filledListsJSON, +// filledDNSBLJSON, filledAbuseIPDBScoresJSON, filledAbuseIPDBChecksSpentJSON +// and filledRouteWatchJSON hold them, and alerts and anomaly counters, as +// filledAlertsJSON holds them, into the parts of params. func fill(params state.Params) { now := midnight() client := netip.MustParsePrefix("203.0.113.9/32") @@ -1895,12 +2249,14 @@ func fill(params state.Params) { }, {Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)}, }) - params.AbuseIPDB.Load(reputation.Checks{ - Day: civil.DateOf(now), Spent: 3, Scores: []reputation.Score{ - {Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)}, - {Client: client, Score: 100, Fetched: now.Add(-time.Hour)}, - }, + params.AbuseIPDB.Load([]reputation.Score{ + {Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)}, + {Client: client, Score: 100, Fetched: now.Add(-time.Hour)}, }) + params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{ + Day: civil.DateOf(now), Spent: 3, + }) + fillRouteWatch(params.RouteWatch) // An alert waiting, a repeat of it the cooldown holds back, another // alert waiting, and one past the two an hour, for the hour's summary. @@ -1926,6 +2282,22 @@ func fill(params state.Params) { }) } +// fillRouteWatch puts the two answers filledRouteWatchJSON holds into +// routeWatch: a route, and an address without a live route. +func fillRouteWatch(routeWatch *routewatch.Client) { + routeWatch.Load([]routewatch.Answer{ + { + Client: netip.MustParsePrefix("203.0.113.9/32"), + Netblock: netip.MustParsePrefix("203.0.113.0/24"), ASN: 64496, + Answered: midnight().Add(-time.Minute), + }, + { + Client: netip.MustParsePrefix("2001:db8::/64"), + Answered: midnight().Add(-2 * time.Minute), + }, + }) +} + // permanentBan is the ban permanentBansJSON holds. func permanentBan() bans.Ban { score := int64(100) @@ -2096,12 +2468,21 @@ func (l processLog) waitFor(t *testing.T, msg string) map[string]any { return nil } -// wantTakenIn waits for the next edit taken in, and checks that it is of -// the state file name in dir. +// wantTakenIn waits for the next edit taken in, of a state file or a cache +// file as name is one, and checks that it is of the file name in dir. func wantTakenIn(t *testing.T, lines processLog, dir, name string) { t.Helper() - line := lines.waitFor(t, tookIn) + var msg string + + switch name { + case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON: + msg = cacheTookIn + default: + msg = tookIn + } + + line := lines.waitFor(t, msg) if line["file"] != filepath.Join(dir, name) { t.Fatalf("took in %v, want an edit of %s", line, name) } @@ -2160,6 +2541,48 @@ func wantRefused(t *testing.T, file, content, want string) { } } +// wantSetAside writes content to the cache file named file in a new +// directory, and checks that Load starts all the same, with the cache +// empty, and sets the file aside, raising a file_error alert and logging +// an error that is the file's path and then starts with want. +func wantSetAside(t *testing.T, file, content, want string) { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, file) + + err := os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", file, err) + } + + params := newParams(dir) + lines := logInto(¶ms) + load(t, params) + + line := lines.waitFor(t, cacheSetAside) + if message, _ := line["error"].(string); line["file"] != path+".bad" || + !strings.HasPrefix(message, path+want) { + t.Errorf("set aside with %v, want %s.bad and an error starting %s%s", line, path, + path, want) + } + + waiting := params.Alerts.Snapshot().Waiting[alerts.DestinationWebhook] + if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError || + waiting[0].Reason != cacheSetAside { + t.Errorf("alerts waiting %+v, want a file_error alert saying %q", waiting, + cacheSetAside) + } + + if len(params.GeoJS.Snapshot()) != 0 || len(params.Lists.Snapshot()) != 0 || + len(params.DNSBL.Snapshot()) != 0 || len(params.AbuseIPDB.Snapshot()) != 0 || + len(params.RouteWatch.Snapshot()) != 0 { + t.Error("a cache holds what a file set aside held") + } + + wantFiles(t, dir, file+".bad") +} + // removeFiles removes the named files from dir. func removeFiles(t *testing.T, dir string, names ...string) { t.Helper() diff --git a/internal/sww/sww.go b/internal/sww/sww.go index 75ed58b..6557e09 100644 --- a/internal/sww/sww.go +++ b/internal/sww/sww.go @@ -244,6 +244,7 @@ func loadStateFiles( ) (*state.Files, error) { return state.Load(state.Params{ Dir: cfg.StateDir, + CacheDir: cfg.CacheDir, WriteDelay: cfg.StateWriteDelay, CounterInterval: cfg.StateCounterInterval, Ledger: server.Ledger, @@ -252,6 +253,7 @@ func loadStateFiles( Lists: server.Lists, DNSBL: server.DNSBL, AbuseIPDB: server.AbuseIPDB, + RouteWatch: server.RouteWatch, Alerts: alertQueue, Anomalies: server.Anomalies, Now: now, diff --git a/internal/sww/sww_test.go b/internal/sww/sww_test.go index fbff72f..d0ce9dd 100644 --- a/internal/sww/sww_test.go +++ b/internal/sww/sww_test.go @@ -38,6 +38,7 @@ const ( stateDir = "SWW_STATE_DIR" stateWriteDelay = "SWW_STATE_WRITE_DELAY" stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" + cacheDir = "SWW_CACHE_DIR" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" rulesDir = "SWW_RULES_DIR" @@ -160,6 +161,12 @@ func run(ctx context.Context, env map[string]string, out *output) int { return "off", true } + // The cache files go beside the state files unless the test + // says otherwise. + if !ok && name == cacheDir { + return env[stateDir], env[stateDir] != "" + } + return value, ok }, Stdout: out, @@ -699,7 +706,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { } } -func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) { +func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) { t.Parallel() const ( @@ -732,10 +739,11 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) t.Cleanup(server.Close) torURL, dropURL := server.URL+torPath, server.URL+dropPath - dir := t.TempDir() + cache := t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", - stateDir: dir, + stateDir: t.TempDir(), + cacheDir: cache, rulesDir: t.TempDir(), metricsToken: token, instanceName: instance, @@ -755,7 +763,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) }) // Restarted with drop.txt named after it and the server answering, tor.txt - // waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json, + // waits SWW_BLOCKLIST_REFRESH after its failed try, kept in lists.json, // while drop.txt, never tried, is fetched at once. Lists are fetched in the // order named, so once drop.txt refuses the client, tor.txt has had its turn. failing.Store(false) @@ -773,8 +781,8 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) } // After another restart, with the server failing, the copy of drop.txt - // kept in reputation.json, its copyright line included, refuses the - // client from the first request. + // kept in lists.json, its copyright line included, refuses the client + // from the first request. failing.Store(true) out = runUntilStopped(t, env, func(url string) { @@ -782,16 +790,16 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) }) wantDeniedByList(t, out.line(t, "type", "request"), dropURL) - path := filepath.Join(dir, "reputation.json") + path := filepath.Join(cache, "lists.json") kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) { - t.Errorf("reputation.json holds\n%s\nwant the copy with %q (%v)", kept, copyright, + t.Errorf("lists.json holds\n%s\nwant the copy with %q (%v)", kept, copyright, err) } } -func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) { +func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) { t.Parallel() const bouncerKey = "crowdsec-key-0123456789abcdef" @@ -814,10 +822,11 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) { })) t.Cleanup(engine.Close) - dir := t.TempDir() + dir, cache := t.TempDir(), t.TempDir() env := map[string]string{ listenAddr: localhost + ":0", stateDir: dir, + cacheDir: cache, rulesDir: t.TempDir(), "SWW_CROWDSEC_LAPI_URL": engine.URL, "SWW_CROWDSEC_LAPI_KEY": bouncerKey, @@ -836,7 +845,7 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) { t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed) } - // Restarted with the engine down, the copy kept in reputation.json bans + // Restarted with the engine down, the copy kept in lists.json bans // another client in the netblock from its first request. down.Store(true) @@ -844,22 +853,25 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) { wantStatus(t, url, "203.0.113.10", http.StatusForbidden) }) - // The key is in neither run's output, nor in a state file. - files := []string{"bans.json", "reputation.json", "clients.json"} + // The key is in neither run's output, nor in a state or cache file. + files := []string{ + filepath.Join(dir, "bans.json"), filepath.Join(dir, "clients.json"), + filepath.Join(cache, "lists.json"), + } shown := make([]string, 0, len(files)+2) shown = append(shown, first.text(), second.text()) - for _, name := range files { - data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // the test's + for _, path := range files { + data, err := os.ReadFile(path) //nolint:gosec // the test's if err != nil { - t.Fatalf("read %s: %v", name, err) + t.Fatalf("read %s: %v", path, err) } shown = append(shown, string(data)) } if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) { - t.Errorf("the key is shown in the output or the state files:\n%s", all) + t.Errorf("the key is shown in the output or the state and cache files:\n%s", all) } } @@ -1207,6 +1219,29 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) { "SWW_STATE_DIR cannot be written: ") } +func TestUnwritableCacheDirStopsTheStart(t *testing.T) { + t.Parallel() + + ctx, stop := context.WithTimeout(t.Context(), waitLimit) + defer stop() + + out := &output{} + + status := run(ctx, map[string]string{ + listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), + cacheDir: filepath.Join(t.TempDir(), "missing"), + }, out) + if status != 1 { + t.Fatalf("exit status %d, want 1", status) + } + + line := out.line(t, "msg", "cannot use the state files") + if message, _ := line["error"].(string); !strings.HasPrefix(message, + "SWW_CACHE_DIR cannot be written: ") { + t.Errorf("start refused with %q, want SWW_CACHE_DIR named", message) + } +} + // wantStartRefused runs sww with its state files in dir, and // checks that it stops at start, with an error that starts with want. If // it starts instead, it is stopped after waitLimit. diff --git a/internal/sww/waf_test.go b/internal/sww/waf_test.go index b6e8fa6..6ebe485 100644 --- a/internal/sww/waf_test.go +++ b/internal/sww/waf_test.go @@ -67,6 +67,7 @@ func TestMainRunsWAF(t *testing.T) { commandForMain+"="+wafCommand, listenAddr+"="+localhost+":0", stateDir+"="+dir, + cacheDir+"="+dir, rulesDir+"="+t.TempDir(), lookupSource+"=off") diff --git a/internal/waf/crowdsec_test.go b/internal/waf/crowdsec_test.go index 368f4dd..9a0243e 100644 --- a/internal/waf/crowdsec_test.go +++ b/internal/waf/crowdsec_test.go @@ -16,7 +16,7 @@ import ( // The CrowdSec settings, and the tests' engine, which is never asked: each // test puts in the copy of its decision list, at decisionsURL, that it -// needs, as reputation.json would at start. +// needs, as lists.json would at start. const ( crowdSecURL = "SWW_CROWDSEC_LAPI_URL" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" @@ -182,7 +182,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan( // loadDecisions puts into server's lists the copy of the decision list at // decisionsURL, answer, the engine's answer, fetched at fetched, as -// reputation.json would at start. +// lists.json would at start. func loadDecisions( t *testing.T, server *waf.Server, fetched time.Time, answer string, ) { diff --git a/internal/waf/proxy.go b/internal/waf/proxy.go index 5c9587c..d050db1 100644 --- a/internal/waf/proxy.go +++ b/internal/waf/proxy.go @@ -86,8 +86,8 @@ type Params struct { // Server is the server waf runs, with the parts whose state the state // files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, -// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while -// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only. +// AbuseIPDB's scores and checks spent, routewatch's answers, and the metrics. +// RouteWatch is nil while SWW_ROUTEWATCH_URL is unset. type Server struct { *http.Server diff --git a/internal/waf/reputation_test.go b/internal/waf/reputation_test.go index 0507f5b..860e335 100644 --- a/internal/waf/reputation_test.go +++ b/internal/waf/reputation_test.go @@ -39,7 +39,7 @@ const ( ) // The lists these tests name, which are never fetched: each test puts in -// the copies it needs, as reputation.json would at start. +// the copies it needs, as lists.json would at start. const ( dropURL = "https://lists.example/drop.txt" torURL = "https://lists.example/tor.txt" @@ -374,7 +374,7 @@ const ( // The DNSBL zones these tests name, which are never asked about the // clients the tests send requests from: each test puts in the verdicts it -// needs, as reputation.json would at start. A query a test does start is +// needs, as dnsbl.json would at start. A query a test does start is // sent to noResolver, where nothing listens, so that none leaves the host. const ( dnsblZone = "dnsbl.example" @@ -759,7 +759,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T clk.advance(time.Hour) wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantAbuseIPDBChecks(t, server, 1) - waitUntil(func() bool { return len(server.AbuseIPDB.Snapshot().Scores) == 1 }) + waitUntil(func() bool { return len(server.AbuseIPDB.Snapshot()) == 1 }) wantReputation(t, s.get(fromDE, http.StatusOK, forward), abuseipdb) s.get(fromKP, http.StatusOK, forward) @@ -1072,7 +1072,7 @@ func verdictsFetched() time.Time { // loadVerdicts puts into server's DNSBL, for each client listedBy names, // a verdict of each zone SWW_DNSBL_ZONES names, fetched at -// verdictsFetched, as reputation.json would at start: one that lists the +// verdictsFetched, as dnsbl.json would at start: one that lists the // client from each zone listedBy gives for it, and one that does not from // each other zone. func loadVerdicts(server *waf.Server, listedBy map[string][]string) { @@ -1091,8 +1091,8 @@ func loadVerdicts(server *waf.Server, listedBy map[string][]string) { } // loadScores puts into server's AbuseIPDB the score scores gives each -// client, an IPv4 address, fetched at verdictsFetched, as reputation.json -// would at start. +// client, an IPv4 address, fetched at verdictsFetched, as +// abuseipdb-scores.json would at start. func loadScores(server *waf.Server, scores map[string]int64) { kept := make([]reputation.Score, 0, len(scores)) for client, score := range scores { @@ -1102,7 +1102,7 @@ func loadScores(server *waf.Server, scores map[string]int64) { }) } - server.AbuseIPDB.Load(reputation.Checks{Scores: kept}) + server.AbuseIPDB.Load(kept) } // wantAbuseIPDBChecks checks how many clients server has checked with @@ -1116,7 +1116,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) { } // loadLists puts copies of lists into server's lists, by URL, each with -// its lines, fetched at listsFetched, as reputation.json would at start. +// its lines, fetched at listsFetched, as lists.json would at start. func loadLists(t *testing.T, server *waf.Server, copies map[string][]string) { t.Helper() diff --git a/script/example-app b/script/example-app index 9ad9026..a85b930 100755 --- a/script/example-app +++ b/script/example-app @@ -9,6 +9,7 @@ # - that a probe for /.env bans another client, which its next request bans # for good; # - that only waf's container changes the state directory's owner; +# - that waf's container makes the cache directory for the sww user; # - that the app still answers through rproxy with waf stopped, and # that once waf has started again, bans apply again; # - that `sv stop` stops rproxy in order, and `docker stop` stops each @@ -153,9 +154,9 @@ refused_from() { [ "$(status_from "$1" "$2")" = 403 ] } -# owner_of_state_dir : print who owns /var/lib/sww there. -owner_of_state_dir() { - docker exec "$1" stat --format '%U' /var/lib/sww +# owner_of : print who owns dir there. +owner_of() { + docker exec "$1" stat --format '%U' "$2" } main() { @@ -173,11 +174,14 @@ main() { start_app echo "example-app: the health checks pass" - [ "$(owner_of_state_dir "$WAF")" = sww ] || + [ "$(owner_of "$WAF" /var/lib/sww)" = sww ] || fail "waf's container did not give the state directory to sww" - [ "$(owner_of_state_dir "$APP")" = root ] || + [ "$(owner_of "$APP" /var/lib/sww)" = root ] || fail "the app's container changed the state directory's owner" echo "example-app: only waf's container changes the state directory's owner" + [ "$(owner_of "$WAF" /var/cache/sww)" = sww ] || + fail "waf's container did not make the cache directory for sww" + echo "example-app: waf's container makes the cache directory for sww" page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" || fail "no answer on port 8080" diff --git a/script/run b/script/run index 57a647b..3792de8 100755 --- a/script/run +++ b/script/run @@ -6,8 +6,9 @@ # waf listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and # rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless # SWW_STATE_DIR is set, the state files go in bin/state, beside the binary, -# and unless SWW_RULES_DIR is set, the rule files are those of share/rules.d, -# which the image ships. +# and unless SWW_CACHE_DIR is set, the cache files go in bin/cache. Unless +# SWW_RULES_DIR is set, the rule files are those of share/rules.d, which the +# image ships. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -31,6 +32,11 @@ main() { export SWW_STATE_DIR mkdir -p "$SWW_STATE_DIR" fi + if [ -z "${SWW_CACHE_DIR+set}" ]; then + SWW_CACHE_DIR="$ROOT/bin/cache" + export SWW_CACHE_DIR + mkdir -p "$SWW_CACHE_DIR" + fi if [ -z "${SWW_RULES_DIR+set}" ]; then SWW_RULES_DIR="$ROOT/share/rules.d" export SWW_RULES_DIR diff --git a/share/sww.run b/share/sww.run index 1f8eeec..14781f8 100755 --- a/share/sww.run +++ b/share/sww.run @@ -11,7 +11,10 @@ main() { local command="${SWW_COMMAND:-rproxy}" case "$command" in rproxy) ;; - waf) give_state_dir_to_sww ;; + waf) + give_state_dir_to_sww + make_cache_dir_for_sww + ;; *) printf 'SWW_COMMAND: "%s" is not rproxy or waf\n' \ "$command" >&2 @@ -31,19 +34,34 @@ main() { # reports a directory it cannot write. give_state_dir_to_sww() { local dir - if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then + if dir="$(setting SWW_STATE_DIR /var/lib/sww)" && + [[ "$dir" == /* && -d "$dir" ]]; then chown -R sww:sww "$dir" || true fi } -# state_dir prints SWW_STATE_DIR, /var/lib/sww when it is unset, -# or what is in the file SWW_STATE_DIR_FILE names, less every newline at -# its end. It fails when both are set or the file cannot be read. -state_dir() { - if [[ ! -v SWW_STATE_DIR_FILE ]]; then - printf '%s\n' "${SWW_STATE_DIR-/var/lib/sww}" - elif [[ ! -v SWW_STATE_DIR ]]; then - cat -- "$SWW_STATE_DIR_FILE" 2>/dev/null +# make_cache_dir_for_sww makes the cache directory, SWW_CACHE_DIR in +# either form, and gives it and every file in it to the sww user, as +# give_state_dir_to_sww does the state directory. Unlike that one, it is +# made when it is missing, since the cache files need no volume. As there, +# a setting that cannot be read or is not an absolute path changes +# nothing, and a failure does not end the script. +make_cache_dir_for_sww() { + local dir + if dir="$(setting SWW_CACHE_DIR /var/cache/sww)" && [[ "$dir" == /* ]]; then + { mkdir -p -- "$dir" && chown -R sww:sww "$dir"; } || true + fi +} + +# setting prints the setting named $1, $2 when it is unset, or what is in +# the file the setting $1_FILE names, less every newline at its end. It +# fails when both are set or the file cannot be read. +setting() { + local name="$1" file_name="$1_FILE" + if [[ ! -v "$file_name" ]]; then + printf '%s\n' "${!name-$2}" + elif [[ ! -v "$name" ]]; then + cat -- "${!file_name}" 2>/dev/null else return 1 fi