Add asn= and prefix= tags from a routewatch instance (closes #204)
check / check (push) Successful in 3m59s

SWW_ROUTEWATCH_URL names the instance. The new heuristic routewatch_tags
asks it in the background about each client and tags the request. Answers
are kept in memory for an hour. A request without tags gives the reason in
routewatch_tags_missing, and it is never delayed or refused.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-09 03:34:04 +00:00
1 parent d7c62f2854
commit 8652e10d06
14 files changed
+1015 -17

No files matched your search

+39 -9
View File
@@ -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
@@ -475,7 +479,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 +489,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 +531,7 @@ func FromEnvironment(
TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.listURL("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"),
@@ -1064,14 +1072,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 +1186,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 +1198,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 +1942,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 +2019,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 +2044,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
}
+50
View File
@@ -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,54 @@ 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)
}
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel()
@@ -2503,6 +2552,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s",
routeWatchURL: "",
crowdSecURL: "",
crowdSecKey: "",
banResponse: "403",