From 6850cb0e9f6fba33b7479bf51f064d89b8b7135d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 9 Nov 2026 05:47:34 +0100 Subject: [PATCH] Add asn= and prefix= tags from a routewatch instance (closes #204) With `SWW_ROUTEWATCH_URL` set, a new heuristic, `routewatch_tags`, adds `asn=` and `prefix=` to a request from what a routewatch instance answers about the client. New package `internal/routewatch` asks in the background, never on a request's path, and keeps answers 1 hour in memory. The request log gains `routewatch_tags_missing`, saying why a request has none. `prefix=` works in `SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT`. Trips a reader: a client's first request has no routewatch tags, so `prefix=` refuses from its next request after the answer comes. Judgement call: with `SWW_LOOKUP_SOURCE=off` and `SWW_ROUTEWATCH_URL` set, an `asn=` tag no longer stops the start. Model: opus-5-5 --- README.md | 9 +- SPEC.md | 25 +- docs/request-log.md | 6 + docs/settings.md | 13 +- internal/config/config.go | 67 +++- internal/config/config_test.go | 59 ++++ internal/discriminator/discriminator.go | 1 + internal/discriminator/heuristics.go | 1 + internal/discriminator/proxy.go | 12 +- internal/discriminator/routewatchtags.go | 42 +++ internal/discriminator/routewatchtags_test.go | 245 +++++++++++++ internal/requestlog/requestlog.go | 4 + internal/routewatch/routewatch.go | 243 +++++++++++++ internal/routewatch/routewatch_test.go | 333 ++++++++++++++++++ 14 files changed, 1043 insertions(+), 17 deletions(-) create mode 100644 internal/discriminator/routewatchtags.go create mode 100644 internal/discriminator/routewatchtags_test.go create mode 100644 internal/routewatch/routewatch.go create mode 100644 internal/routewatch/routewatch_test.go diff --git a/README.md b/README.md index 48d9cba..e70ade3 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ Every setting the code reads, with its default: | `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | | `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | | `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | +| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | | `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | | `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | | `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | @@ -567,7 +568,9 @@ the `tags` field of its log line: or `vpn=mullvad`; - `crowdsec`, `dnsbl=` and `abuseipdb`, when that reputation source lists the client; -- `exploitsignature`, when a rule file or the Core Rule Set matched the request. +- `exploitsignature`, when a rule file or the Core Rule Set matched the request; +- `asn=` and `prefix=`, from a routewatch instance, while + `SWW_ROUTEWATCH_URL` is set. `SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). @@ -630,8 +633,8 @@ is set aside as `.bad`, and never stops the running process (see - `internal/discriminator`: `discriminator`'s server, which runs the checks and answers its own endpoints. - `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`, - `internal/lookup`, `internal/reputation`, `internal/anomaly` and - `internal/alerts`: one check or source each. + `internal/lookup`, `internal/reputation`, `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. diff --git a/SPEC.md b/SPEC.md index 888626e..265cad2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -87,6 +87,8 @@ to them. database. - `internal/reputation`: fetches the lists, asks the DNS blocklists and AbuseIPDB in the background, and keeps their answers. + - `internal/routewatch`: asks a routewatch instance about clients in the + background, and keeps its answers. - `internal/anomaly`: the anomaly counters and their alerts. - `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 @@ -144,7 +146,9 @@ request log's `heuristic` field gives that name. `refusal_limit` and around its address, a /64 by default, since one abuser usually controls a whole /64. A client is therefore one IPv4 address or one IPv6 group. 3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the - client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag. + client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and + `prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in + `SWW_ALLOW_NETS` gets no tag. 4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte limits and the error burst. A client in `SWW_DENY_NETS` is refused (`deny_nets`); a client in both lists is let through. @@ -554,6 +558,21 @@ Each tag and where it comes from: - `exploitsignature`, when a rule file or the Core Rule Set matched the request. For the Core Rule Set a match is a score at or over `SWW_WAF_ANOMALY_THRESHOLD`. +- `asn=` and `prefix=` from a routewatch instance + (`routewatch_tags`), while `SWW_ROUTEWATCH_URL` is set: + - The tag `prefix=` holds the netblock the client's address is announced in, + such as `prefix=192.0.2.0/24`. + - An `asn=` tag has the same spelling as the lookup's. When routewatch and + the lookup give the same AS number, a request has one `asn=` tag. When + they differ, it has both. + - They are added before `deny_nets`, so the log line of a request it refused + has them. + - `discriminator` asks routewatch in the background and never waits for it. + An answer is kept for 1 hour, in memory only. + - 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 + [`docs/request-log.md`](docs/request-log.md)). A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and @@ -571,7 +590,9 @@ Two settings act on tags: The tag settings are empty by default, for the same reason as the country list. The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A -setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. +setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. The +exception is an `asn=` tag while `SWW_ROUTEWATCH_URL` is set, since routewatch +gives that tag. ### Biased thresholds diff --git a/docs/request-log.md b/docs/request-log.md index 8bd5d5e..8887203 100644 --- a/docs/request-log.md +++ b/docs/request-log.md @@ -173,6 +173,12 @@ Every one of these lines has: - `abuseipdb`. - `tags`: the request's tags, sorted, left out when it has none. Each tag and where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md). +- `routewatch_tags_missing`: why the request got no tags from routewatch, + present only while `SWW_ROUTEWATCH_URL` is set: + - `no_answer_yet`: routewatch has not answered about this client yet; + - `request_failed`: a query to routewatch failed within the last minute, and + the process log says why; + - `no_route`: routewatch knows no live route for the address. - `offence`: `limit`, for a request that broke a limit. - `ban_expires`: when the client's ban ends, for a request that made one or was refused under one: a time, or `permanent`. diff --git a/docs/settings.md b/docs/settings.md index 301c02f..0250caa 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -145,8 +145,8 @@ cut off; the client is banned. the start while a setting that needs a lookup is set: - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - `SWW_ADD_LOOKUP_HEADERS`; - - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=` - tag; + - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an + `asn=` tag while `SWW_ROUTEWATCH_URL` is unset; - the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`; - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - an anomaly threshold per AS number. @@ -186,11 +186,20 @@ Every request gets tags from what `discriminator` knows of its client. See `exploitsignature`: a request gets that tag after the rate limits have counted it. +- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a + routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a + service that gives an address's announced netblock and origin AS. + `discriminator` asks it about each client, in the background, and adds the + tags `asn=` and `prefix=` from its answer. While it is + unset, nothing asks routewatch and no request gets a `prefix=` tag. + `SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag that no request can get stops the start. A request can get only these: - a name in `SWW_TAG_LISTS`; - an `asn=` or `country=` tag; +- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches + only that exact announced netblock, such as `prefix=192.0.2.0/24`; - a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key masked as the `reputation` field shows it; - `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set; diff --git a/internal/config/config.go b/internal/config/config.go index 8f77579..a25171f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "math" "net" "net/http" @@ -106,6 +107,9 @@ type Config struct { TagLists []TagList TagDeny []string TagLimitPercent map[string]int64 + // RouteWatchURL is where the routewatch instance is that adds the asn= + // and prefix= tags, "" for none (SWW_ROUTEWATCH_URL). + RouteWatchURL string // The biased thresholds. ASNBytesPercent and CountryBytesPercent give // those they list a percentage of the byte limits in place of the one // TagLimitPercent gives their asn= or country= tag @@ -462,6 +466,9 @@ var ( errNotLAPIURL = errors.New( "is not an http or https URL without a user or a fragment, " + "such as http://172.17.0.1:8080") + errNotRouteWatchURL = errors.New( + "is not an http or https URL without a user or a fragment, " + + "such as https://routewatch.example") errNeedsLAPIKey = errors.New("the engine answers no request without it") errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") errAnotherList = errors.New( @@ -475,7 +482,9 @@ var ( "is not a tag, : and a percentage, such as vpn:50 or asn=14061:25") errNotTagPercent = errors.New("is not a percentage from 1 to 99") errNotASNTag = errors.New("is not asn= and a number, such as asn=14061") - errExploitTag = errors.New( + errNotPrefixTag = errors.New( + "is not prefix= and a netblock, such as prefix=192.0.2.0/24") + errExploitTag = errors.New( "cannot lower limits: the rate limits count a request before its " + "rules are matched") errCrowdSecTag = errors.New( @@ -483,8 +492,9 @@ var ( errNoRequestGetsTag = errors.New( "is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " + "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + - "abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " + - "rule files or the Core Rule Set on") + "a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " + + "SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " + + "Core Rule Set on") ) // FromEnvironment reads the settings with lookupEnv, normally @@ -524,6 +534,7 @@ func FromEnvironment( TagLists: env.tagLists("SWW_TAG_LISTS"), TagDeny: env.tags("SWW_TAG_DENY"), TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"), + RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"), ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN), CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry), UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"), @@ -1019,6 +1030,22 @@ func (e *environment) crowdSecDecisionsURL(name string) string { return lapi.JoinPath("v1", "decisions").String() } +// routeWatchURL reads the setting that is the URL of a routewatch instance, +// "" while it is unset or empty. +func (e *environment) routeWatchURL(name string) string { + value := e.value(name, "") + if value == "" { + return "" + } + + instance, err := url.Parse(value) + if err != nil || !isHTTPURL(instance) { + e.check(name, fmt.Errorf("%q %w", value, errNotRouteWatchURL)) + } + + return value +} + // lookupSource reads the setting that is where clients are looked up: // geojs, file, or off. func (e *environment) lookupSource(name, defaultValue string) string { @@ -1064,14 +1091,21 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) { return } + // routewatch gives asn= tags too. + onlyLookupGives := func(tag string) bool { + return isLookupTag(tag) && + (cfg.RouteWatchURL == "" || !strings.HasPrefix(tag, ASNTagPrefix)) + } + for _, setting := range []struct { name string set bool }{ {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, - {"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)}, - {"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)}, + {"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)}, + {"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc( + slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)}, {"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, {"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, {"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, @@ -1171,6 +1205,10 @@ func requestCanGetTag(cfg *Config, tag string) bool { return cfg.AbuseIPDBKey != "" } + if strings.HasPrefix(tag, PrefixTagPrefix) { + return cfg.RouteWatchURL != "" + } + zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix) return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool { @@ -1179,7 +1217,7 @@ func requestCanGetTag(cfg *Config, tag string) bool { } // TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT -// names an asn= or country= tag, which only the lookup can give a request. +// names an asn= or country= tag. func (c *Config) TagsNeedLookup() bool { return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) } @@ -1923,6 +1961,7 @@ const ( ASNTagPrefix = "asn=" CountryTagPrefix = "country=" DNSBLTagPrefix = "dnsbl=" + PrefixTagPrefix = "prefix=" AbuseIPDBTag = "abuseipdb" CrowdSecTag = "crowdsec" // ExploitSignatureTag is the tag of a request a rule file or the Core @@ -1999,9 +2038,10 @@ func parseTags(value string) ([]string, error) { // parseTag reads a tag in either case, with spaces around it, and returns it // as a request carries it: in lower case, but for a country code, which is in -// capitals, and an asn= tag's number without leading zeros. An asn= or -// country= tag must name a number or a country, so that a mistyped one stops -// the start instead of never matching. +// capitals, an asn= tag's number without leading zeros, and a prefix= tag's +// netblock as routewatch writes it. An asn=, country= or prefix= tag must name +// a number, a country or a netblock, so that a mistyped one stops the start +// instead of never matching. func parseTag(value string) (string, error) { tag := strings.ToLower(strings.TrimSpace(value)) @@ -2023,6 +2063,15 @@ func parseTag(value string) (string, error) { return CountryTagPrefix + country, nil } + if text, isPrefix := strings.CutPrefix(tag, PrefixTagPrefix); isPrefix { + netblock, err := netip.ParsePrefix(text) + if err != nil || netblock != netblock.Masked() { + return "", fmt.Errorf("%q %w", value, errNotPrefixTag) + } + + return PrefixTagPrefix + netblock.String(), nil + } + return tag, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7944890..29aecb8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -61,6 +61,7 @@ const ( reputationAction = "SWW_REPUTATION_ACTION" reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL" reputationTimeout = "SWW_REPUTATION_TIMEOUT" + routeWatchURL = "SWW_ROUTEWATCH_URL" crowdSecURL = "SWW_CROWDSEC_LAPI_URL" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY" banResponse = "SWW_BAN_RESPONSE" @@ -1403,6 +1404,63 @@ func TestTagSettingsAsSet(t *testing.T) { } } +func TestRouteWatchURLAndPrefixTags(t *testing.T) { + t.Parallel() + + const rw = "https://routewatch.example" + + if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" { + t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL) + } + + cfg := fromEnvironment(t, environment{ + routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32", + tagLimitPercent: "prefix=192.0.2.0/24:50", + }) + wantDeny := []string{"prefix=2001:db8::/32"} + + if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) || + cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 { + t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent) + } + + // routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set. + fromEnvironment(t, environment{ + lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496", + }) + + for _, stopped := range []struct { + env environment + name string + }{ + {environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL}, + {environment{routeWatchURL: "routewatch.example"}, routeWatchURL}, + {environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny}, + {environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny}, + {environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny}, + { + environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"}, + tagLimitPercent, + }, + {environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny}, + {environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny}, + } { + _, err := config.FromEnvironment(stopped.env.lookupEnv) + if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") { + t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name) + } + } + + badURL := environment{routeWatchURL: "ftp://routewatch.example"} + _, err := config.FromEnvironment(badURL.lookupEnv) + + want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` + + "without a user or a fragment, such as https://routewatch.example" + if err == nil || err.Error() != want { + t.Errorf("error %v, want %s", err, want) + } +} + func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) { t.Parallel() @@ -2503,6 +2561,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { reputationAction: "limit:25", reputationCacheTTL: defaultReputationCacheTTL, reputationTimeout: "2s", + routeWatchURL: "", crowdSecURL: "", crowdSecKey: "", banResponse: "403", diff --git a/internal/discriminator/discriminator.go b/internal/discriminator/discriminator.go index d704384..9651a04 100644 --- a/internal/discriminator/discriminator.go +++ b/internal/discriminator/discriminator.go @@ -123,6 +123,7 @@ func New(params Params) *Server { Lists: h.lists, DNSBL: h.dnsbl, AbuseIPDB: h.abuseIPDB, + RouteWatch: h.routeWatch, Metrics: h.metrics, } } diff --git a/internal/discriminator/heuristics.go b/internal/discriminator/heuristics.go index beef660..605d4a4 100644 --- a/internal/discriminator/heuristics.go +++ b/internal/discriminator/heuristics.go @@ -44,6 +44,7 @@ const ( func beforeLookup() []heuristic { return []heuristic{ tagLists{}, + routeWatchTags{}, denyNets{}, activeBan{}, } diff --git a/internal/discriminator/proxy.go b/internal/discriminator/proxy.go index 7b15d8b..b60acb1 100644 --- a/internal/discriminator/proxy.go +++ b/internal/discriminator/proxy.go @@ -20,6 +20,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" "sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/waf" ) @@ -85,7 +86,8 @@ type Params struct { // Server is the server discriminator 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. +// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while +// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only. type Server struct { *http.Server @@ -97,6 +99,7 @@ type Server struct { Lists *reputation.Lists DNSBL *reputation.DNSBL AbuseIPDB *reputation.AbuseIPDB + RouteWatch *routewatch.Client Metrics *metrics.Metrics } @@ -152,6 +155,12 @@ func newHandler(params Params) *handler { coreRuleSet: params.CoreRuleSet, alerts: params.Alerts, } + if params.Config.RouteWatchURL != "" { + h.routeWatch = routewatch.New(routewatch.Params{ + URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog, + }) + } + h.geojs = lookup.New(lookup.Params{ URL: params.GeoJSURL, Timeout: params.Config.LookupTimeout, @@ -255,6 +264,7 @@ type handler struct { lists *reputation.Lists dnsbl *reputation.DNSBL abuseIPDB *reputation.AbuseIPDB + routeWatch *routewatch.Client rules *rules.Files coreRuleSet *waf.CoreRuleSet alerts *alerts.Queue diff --git a/internal/discriminator/routewatchtags.go b/internal/discriminator/routewatchtags.go new file mode 100644 index 0000000..33b469d --- /dev/null +++ b/internal/discriminator/routewatchtags.go @@ -0,0 +1,42 @@ +package discriminator + +// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL. + +import ( + "context" + "strconv" + "time" + + "sneak.berlin/go/smallwebwaf/internal/config" +) + +// routeWatchTags adds to the request the tags asn= and prefix= +// from what routewatch answered about the client. It refuses nothing, and the +// request does not wait for routewatch. A request that gets no tags from it has +// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL +// is unset it does nothing. Answers are kept by client and asked for by the +// request's address, and a route without an origin AS gives only the prefix= tag. +type routeWatchTags struct{} + +func (routeWatchTags) name() string { return "routewatch_tags" } + +func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict { + if rq.h.routeWatch == nil { + return nil + } + + route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client) + if missing != "" { + rq.line.RouteWatchTagsMissing = string(missing) + + return nil + } + + rq.addTag(config.PrefixTagPrefix + route.Netblock.String()) + + if route.ASN != 0 { + rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10)) + } + + return nil +} diff --git a/internal/discriminator/routewatchtags_test.go b/internal/discriminator/routewatchtags_test.go new file mode 100644 index 0000000..7f02aea --- /dev/null +++ b/internal/discriminator/routewatchtags_test.go @@ -0,0 +1,245 @@ +package discriminator_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/discriminator" + "sneak.berlin/go/smallwebwaf/internal/requestlog" +) + +// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field +// that says why a request got no tags from routewatch. +const ( + routeWatchURL = "SWW_ROUTEWATCH_URL" + tagsMissing = "routewatch_tags_missing" + fromV6 = "2001:db8:1::5" + prefixV6 = "2001:db8::/32" + prefixDE = "203.0.113.0/24" + prefixKP = "198.51.100.0/24" +) + +// standInRoute is what the stand-in for routewatch answers about an address. +type standInRoute struct { + Netblock string `json:"netblock"` + ASN int `json:"asn"` +} + +// startRouteWatch starts a stand-in for routewatch's IP info API that knows the +// routes given by address, and returns its URL. +func startRouteWatch(t *testing.T, routes map[string]standInRoute) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/") + + result := map[string]any{"ip": address} + if route, known := routes[address]; known { + result["netblock"], result["asn"] = route.Netblock, route.ASN + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "data": map[string]any{"results": []any{result}}, + }) + })) + t.Cleanup(server.Close) + + return server.URL +} + +// waitForRouteWatch waits until routewatch's answer about from, in the client +// group of the given length, is kept, so that the next request gets its tags. +func waitForRouteWatch( + t *testing.T, server *discriminator.Server, from string, bits int, +) { + t.Helper() + + addr := netip.MustParseAddr(from) + client := netip.PrefixFrom(addr, bits).Masked() + deadline := time.Now().Add(5 * time.Second) + + for { + _, missing := server.RouteWatch.Route(context.Background(), client, addr) + if missing == "" { + return + } + + if time.Now().After(deadline) { + t.Fatalf("routewatch's answer about %s not kept: %q", from, missing) + } + + time.Sleep(time.Millisecond) + } +} + +func wantTagsMissing(t *testing.T, line logLine, want string) { + t.Helper() + + got, _ := line.fields[tagsMissing].(string) + if got != want { + t.Errorf("log line has %s %q, want %q", tagsMissing, got, want) + } +} + +func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) { + t.Parallel() + + url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}}) + s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url}) + + waitForRouteWatch(t, server, fromDE, 32) + + line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) + wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE) + wantTagsMissing(t, line, "") +} + +func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) { + t.Parallel() + + url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}}) + s, _, server := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: url, denyNets: fromKP, + }) + + waitForRouteWatch(t, server, fromKP, 32) + + line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied) + wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`) + wantTags(t, line, "asn=64511", "prefix="+prefixKP) +} + +func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) { + t.Parallel() + + url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}}) + s, _, server := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: url, + }) + + waitForRouteWatch(t, server, fromDE, 32) + + wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), + "prefix="+prefixDE) +} + +func TestUnsetRouteWatchChangesNothing(t *testing.T) { + t.Parallel() + + s, _, server := startWithClock(t, "", map[string]string{lookupSource: off}) + + if server.RouteWatch != nil { + t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset") + } + + line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) + if _, present := line.fields[tagsMissing]; present { + t.Errorf("log line has %s", tagsMissing) + } + + if _, present := line.fields["tags"]; present { + t.Errorf("log line has tags %v", line.fields["tags"]) + } +} + +func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) { + t.Parallel() + + // A stand-in that is closed at once, so that the port refuses connections. + dead := httptest.NewServer(http.NotFoundHandler()) + url := dead.URL + + dead.Close() + + s, _, _ := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: url, + }) + + wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), + "no_answer_yet") + + deadline := time.Now().Add(5 * time.Second) + + for { + line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) + + missing, _ := line.fields[tagsMissing].(string) + if missing == "request_failed" { + return + } + + if time.Now().After(deadline) { + t.Fatalf("still %q, want request_failed", missing) + } + + time.Sleep(time.Millisecond) + } +} + +func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) { + t.Parallel() + + s, _, _ := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: startRouteWatch(t, nil), + }) + + deadline := time.Now().Add(5 * time.Second) + + for { + line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) + + missing, _ := line.fields[tagsMissing].(string) + if missing == "no_route" { + return + } + + if time.Now().After(deadline) { + t.Fatalf("still %q, want no_route", missing) + } + + time.Sleep(time.Millisecond) + } +} + +func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) { + t.Parallel() + + url := startRouteWatch(t, map[string]standInRoute{ + fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511}, + }) + s, _, server := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP, + }) + + waitForRouteWatch(t, server, fromKP, 32) + waitForRouteWatch(t, server, fromDE, 32) + + wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied), + "tag_deny", `{"tag": "prefix=`+prefixKP+`"}`) + s.get(fromDE, http.StatusOK, requestlog.ActionForward) +} + +func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) { + t.Parallel() + + url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}}) + s, _, server := startWithClock(t, "", map[string]string{ + lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2", + tagLimitPercent: "prefix=" + prefixV6 + ":50", + }) + + waitForRouteWatch(t, server, fromV6, 64) + + // Half of two is one: the second request breaks the limit. + line := s.get(fromV6, http.StatusOK, requestlog.ActionForward) + wantTags(t, line, "asn=64496", "prefix="+prefixV6) + s.get(fromV6, http.StatusForbidden, requestlog.ActionRateLimited) +} diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index 65bea8f..504c710 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -182,6 +182,10 @@ type Line struct { Reputation []string `json:"reputation,omitempty"` // Tags are the request's tags, sorted. Tags []string `json:"tags,omitempty"` + // RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the + // request got no tags from routewatch: no_answer_yet, request_failed or + // no_route. + RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"` // Offence is the offence the request was held as, OffenceLimit. Offence string `json:"offence,omitempty"` // BanExpires is when the ban the request made, or was refused under, diff --git a/internal/routewatch/routewatch.go b/internal/routewatch/routewatch.go new file mode 100644 index 0000000..ed4e701 --- /dev/null +++ b/internal/routewatch/routewatch.go @@ -0,0 +1,243 @@ +// 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. +package routewatch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/netip" + "net/url" + "sync" + "time" + + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +const ( + // answerKept is how long an answer is used after it came. + answerKept = time.Hour + // failureKept is how long routewatch is not asked again after a query + // fails, so that a dead routewatch is not asked on every request. + failureKept = time.Minute + // queryTimeout is how long one query may take. routewatch answers only + // after a reverse DNS lookup it allows 2 seconds, so this is longer. + queryTimeout = 5 * time.Second + // maxAnswers is how many answers are kept. Past it, the one that came + // longest ago is dropped. + maxAnswers = 100000 + // maxQueries is how many queries may be under way at once. Past it, a + // client is not asked about until its next request, so that a swarm of + // new addresses cannot fill the memory. + maxQueries = 100 + // maxAnswerBytes is the most of an answer that is read. + maxAnswerBytes = 64 << 10 +) + +// Missing is why Route gives no route. The request log's field +// routewatch_tags_missing gives these values. +type Missing string + +const ( + // NoAnswerYet is a client routewatch has not answered about yet. + NoAnswerYet Missing = "no_answer_yet" + // RequestFailed is no answer because a recent query failed. + RequestFailed Missing = "request_failed" + // NoRoute is routewatch knowing no live route for the address. + NoRoute Missing = "no_route" +) + +var ( + errStatus = errors.New("routewatch answered") + errNoResult = errors.New("the answer holds no result") + errNotNetblock = errors.New("the answer's netblock is not a netblock") +) + +// Route is what routewatch knows of an address: the netblock it is announced +// in, and the AS that originates it. +type Route struct { + Netblock netip.Prefix + ASN uint32 +} + +// Params are what New needs. +type Params struct { + // URL is where routewatch is (SWW_ROUTEWATCH_URL). + URL string + // Now tells the time, normally time.Now in UTC. + Now func() time.Time + // ProcessLog receives the query that fails, and why. + ProcessLog *slog.Logger +} + +// Client asks a routewatch instance about clients, in the background, and keeps +// its answers. It is safe for concurrent use. +type Client struct { + params Params + httpClient *http.Client + + 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] + // 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) + if err != nil { + panic(err) // NewLRU fails only for a size below one + } + + return &Client{ + params: params, + httpClient: &http.Client{}, + answers: answers, + asking: map[netip.Prefix]bool{}, + } +} + +// Route returns what routewatch answered about client, an IPv4 address or an +// IPv6 group, or why it has no route. It never waits for a query. A client +// without an answer is asked about in the background, by addr, the address its +// request came from, unless its query is under way, a query failed less than +// failureKept ago, or maxQueries are under way. ctx is the context of the +// client's request, and a query goes on after the request ends. +func (c *Client) Route( + ctx context.Context, client netip.Prefix, addr netip.Addr, +) (Route, Missing) { + c.mu.Lock() + defer c.mu.Unlock() + + now := c.params.Now() + + kept, found := c.answers.Peek(client) + if found && now.Sub(kept.came) < answerKept { + return kept.route, kept.missing + } + + if now.Before(c.retryAt) { + return Route{}, RequestFailed + } + + if !c.asking[client] && len(c.asking) < maxQueries { + c.asking[client] = true + + go c.ask(context.WithoutCancel(ctx), client, addr) + } + + return Route{}, NoAnswerYet +} + +// 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) + now := c.params.Now() + + c.mu.Lock() + + delete(c.asking, client) + + firstFailure := err != nil && !now.Before(c.retryAt) + + if err == nil { + c.answers.Add(client, answer{route: route, missing: missing, came: now}) + } else { + c.retryAt = now.Add(failureKept) + } + + c.mu.Unlock() + + if firstFailure { + c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error()) + } +} + +// 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) { + 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) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return Route{}, "", fmt.Errorf("make the request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + res, err := c.httpClient.Do(req) + 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)) + } + + defer func() { + _ = res.Body.Close() + }() + + if res.StatusCode != http.StatusOK { + 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) { + var decoded struct { + Data struct { + Results []struct { + Netblock string `json:"netblock"` + ASN uint32 `json:"asn"` + } `json:"results"` + } `json:"data"` + } + + err := json.NewDecoder(body).Decode(&decoded) + if err != nil { + return Route{}, "", fmt.Errorf("read the answer: %w", err) + } + + if len(decoded.Data.Results) == 0 { + return Route{}, "", errNoResult + } + + result := decoded.Data.Results[0] + if result.Netblock == "" { + return Route{}, NoRoute, nil + } + + netblock, err := netip.ParsePrefix(result.Netblock) + if err != nil { + return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock) + } + + return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil +} diff --git a/internal/routewatch/routewatch_test.go b/internal/routewatch/routewatch_test.go new file mode 100644 index 0000000..60c197a --- /dev/null +++ b/internal/routewatch/routewatch_test.go @@ -0,0 +1,333 @@ +package routewatch_test + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/routewatch" +) + +const ( + clientAddr = "192.0.2.1" + routedAnswer = `{"status":"ok","data":{"results":[` + + `{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}` + noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}` + // slowAnswer is longer than routewatch allows a reverse DNS lookup. + slowAnswer = 2500 * time.Millisecond + // waitLimit is longer than the Client allows a query. + waitLimit = 10 * time.Second +) + +// clock is a time the tests move by hand. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.now +} + +func (c *clock) advance(by time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.now = c.now.Add(by) +} + +// fixture is a Client asking a stand-in for routewatch. queries counts what the +// stand-in was asked, paths holds the last path it was asked for, and log holds +// the process log. +type fixture struct { + t *testing.T + server *httptest.Server + client *routewatch.Client + clk *clock + queries *atomic.Int32 + path *atomic.Value + log *syncBuffer +} + +// syncBuffer is a buffer the background queries can log to while a test reads it. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.String() +} + +func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture { + t.Helper() + + f := &fixture{ + t: t, + clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)}, + queries: &atomic.Int32{}, + path: &atomic.Value{}, + log: &syncBuffer{}, + } + + f.server = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + f.queries.Add(1) + f.path.Store(r.URL.Path) + standIn(w, r) + })) + t.Cleanup(f.server.Close) + + f.client = routewatch.New(routewatch.Params{ + URL: f.server.URL + "/", Now: f.clk.Now, + ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)), + }) + + return f +} + +func answering(body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + } +} + +func (f *fixture) route() (routewatch.Route, routewatch.Missing) { + addr := netip.MustParseAddr(clientAddr) + client := netip.PrefixFrom(addr, addr.BitLen()) + + return f.client.Route(context.Background(), client, addr) +} + +// waitFor asks until the answer is why, or, while why is "", until there is a +// route, and returns the route. +func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route { + f.t.Helper() + + deadline := time.Now().Add(waitLimit) + + for { + route, missing := f.route() + if missing == why { + return route + } + + if time.Now().After(deadline) { + f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why) + } + + time.Sleep(time.Millisecond) + } +} + +// wantFailureLoggedWithoutTheClient waits for the process log to name a failed +// query, and checks that it does not name the client. +func (f *fixture) wantFailureLoggedWithoutTheClient() { + f.t.Helper() + + // The failure is noted before it is logged. + deadline := time.Now().Add(waitLimit) + for !strings.Contains(f.log.String(), "asking routewatch failed") { + if time.Now().After(deadline) { + f.t.Fatalf("process log %q names no failure", f.log.String()) + } + + time.Sleep(time.Millisecond) + } + + if strings.Contains(f.log.String(), clientAddr) { + f.t.Errorf("process log %q names the client", f.log.String()) + } +} + +func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) { + t.Parallel() + + f := newFixture(t, answering(routedAnswer)) + + if _, missing := f.route(); missing != routewatch.NoAnswerYet { + t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet) + } + + route := f.waitFor("") + + want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} + if route != want { + t.Errorf("route %+v, want %+v", route, want) + } + + if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr { + t.Errorf("asked for path %v", got) + } +} + +func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) { + t.Parallel() + + f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(slowAnswer) + + _, _ = w.Write([]byte(routedAnswer)) + }) + + f.route() + + route := f.waitFor("") + + want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} + if route != want { + t.Errorf("route %+v, want %+v", route, want) + } +} + +func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) { + t.Parallel() + + f := newFixture(t, answering(noRouteAnswer)) + + f.route() + f.waitFor(routewatch.NoRoute) + + if got := f.queries.Load(); got != 1 { + t.Errorf("%d queries, want 1", got) + } +} + +func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) { + t.Parallel() + + tests := map[string]http.HandlerFunc{ + "500": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + "not JSON": answering("no"), + "no results": answering(`{"status":"ok","data":{"results":[]}}`), + "bad netblock": answering( + `{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`), + } + for name, standIn := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t, standIn) + + f.route() + f.waitFor(routewatch.RequestFailed) + f.wantFailureLoggedWithoutTheClient() + }) + } +} + +func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) { + t.Parallel() + + f := newFixture(t, answering(routedAnswer)) + + f.server.Close() + + f.route() + f.waitFor(routewatch.RequestFailed) + f.wantFailureLoggedWithoutTheClient() +} + +func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) { + t.Parallel() + + f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + f.route() + f.waitFor(routewatch.RequestFailed) + f.route() + + if got := f.queries.Load(); got != 1 { + t.Errorf("%d queries while the failure is kept, want 1", got) + } + + f.clk.advance(time.Minute) + f.route() + + deadline := time.Now().Add(waitLimit) + for f.queries.Load() != 2 { + if time.Now().After(deadline) { + t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load()) + } + + time.Sleep(time.Millisecond) + } +} + +func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) { + t.Parallel() + + f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + }) + + start := time.Now() + + f.route() + + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("Route took %s while routewatch hangs", elapsed) + } + + f.waitFor(routewatch.RequestFailed) + f.wantFailureLoggedWithoutTheClient() +} + +func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { + <-release + + _, _ = w.Write([]byte(routedAnswer)) + }) + + for range 50 { + f.route() + } + + close(release) + f.waitFor("") + + for range 50 { + f.route() + } + + if got := f.queries.Load(); got != 1 { + t.Errorf("%d queries for repeated requests, want 1", got) + } + + f.clk.advance(time.Hour) + f.waitFor(routewatch.NoAnswerYet) + f.waitFor("") + + if got := f.queries.Load(); got != 2 { + t.Errorf("%d queries after the answer expired, want 2", got) + } +}