From 50c3f61d390a65842b8f0c8030e3c0520fab3302 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 14:30:52 +0000 Subject: [PATCH] Settings code: one file per daemon (closes #227) internal/config now keeps waf's settings in waf.go and rproxy's in rproxy.go. config.go keeps what both read: the package doc, SWW_COMMAND, the environment reader, listen addresses, sizes and netblock lists. The app URL reader and the default trusted proxies, which only rproxy uses, move to rproxy.go. config_test.go becomes waf_test.go, beside rproxy_test.go. Code is moved, not changed. Model: opus-5-5 --- internal/config/config.go | 2328 +---------------- internal/config/rproxy.go | 47 + internal/config/waf.go | 2293 ++++++++++++++++ .../config/{config_test.go => waf_test.go} | 0 4 files changed, 2344 insertions(+), 2324 deletions(-) create mode 100644 internal/config/waf.go rename internal/config/{config_test.go => waf_test.go} (100%) diff --git a/internal/config/config.go b/internal/config/config.go index be04461..7d9fab7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,628 +8,43 @@ package config import ( - "crypto/x509" "errors" "fmt" "log/slog" - "maps" "math" "net" - "net/http" "net/netip" - "net/url" "os" - "path/filepath" - "slices" "strconv" "strings" - "time" - "unicode" - "unicode/utf8" - - "sneak.berlin/go/smallwebwaf/internal/alerts" - "sneak.berlin/go/smallwebwaf/internal/anomaly" - "sneak.berlin/go/smallwebwaf/internal/remotelog" ) -// Config is the settings of `sww waf`. A time or size limit of -// zero is off. -type Config struct { - // ListenAddr is where waf listens (SWW_LISTEN_ADDR). - ListenAddr string - // InstanceName is the name every log line and alert gives as instance, - // and every metric carries as its label instance (SWW_INSTANCE_NAME), - // by default the host's name, which docker sets to the first 12 - // characters of the container's id. - InstanceName string - // Observe is true in observe mode, when SWW_MODE is observe rather - // than enforce: a request that SWW_DENY_NETS, a ban, the country - // lists, a rate limit, a rule or the Core Rule Set would refuse is - // allowed instead, and no ban is made. - Observe bool - // IPv6GroupPrefix is the length of the IPv6 netblock that is one client - // (SWW_IPV6_GROUP_PREFIX), from 32 to 128. - IPv6GroupPrefix int - // MaxTrackedClients is the most clients the table of clients holds, in - // memory and in clients.json (SWW_MAX_TRACKED_CLIENTS). - MaxTrackedClients int - // AllowNets are the netblocks whose clients skip every check - // (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the - // rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS). - // DenyNets are those whose clients are always refused - // (SWW_DENY_NETS). - AllowNets []netip.Prefix - RateLimitExemptNets []netip.Prefix - DenyNets []netip.Prefix - // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the - // most requests a client may make in a minute, an hour and a day - // (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and - // SWW_RATE_LIMIT_PER_DAY). - RateLimitPerMinute int64 - RateLimitPerHour int64 - RateLimitPerDay int64 - // RateLimitExemptPaths are the path prefixes whose requests the rate - // limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS). - // Each starts with /. - RateLimitExemptPaths []string - // BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the - // most bytes a client's requests may carry in a minute, an hour and a - // day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and - // SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count - // toward them (SWW_BYTES_COUNT): response, request or both. - BytesLimitPerMinute int64 - BytesLimitPerHour int64 - BytesLimitPerDay int64 - BytesCount string - // LookupSource is where each client's AS number and country are - // looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere. - // LookupDBPath is the lookup database, the IPinfo Lite file looked up - // in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any - // other source. A request waits up to LookupTimeout for its client's - // first answer from GeoJS while a setting needs it - // (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true - // when the app is passed the client's AS number and country in headers - // (SWW_ADD_LOOKUP_HEADERS). - LookupSource string - LookupDBPath string - LookupTimeout time.Duration - AddLookupHeaders bool - // ExclusivelyAllowedCountries, when not empty, are the only countries - // whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES), - // two-letter codes in capitals, as GeoJS gives them. - ExclusivelyAllowedCountries []string - // TagLists are the address lists that each add a tag to the requests of - // the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose - // requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients - // with a tag that percentage of every rate limit and byte limit, from 1 - // to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in - // lower case, a country code in capitals. - 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 - // (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds - // percentages from 0 to 100, by AS number, written as AS64496, or by - // country, a two-letter code in capitals, as the lookup gives them. - // UnknownLimitPercent is the percentage of every limit a client without - // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is - // where a file of AS:percent lines is fetched from, whose percentages - // count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL), - // "" while it is unset. - ASNBytesPercent map[string]int64 - CountryBytesPercent map[string]int64 - UnknownLimitPercent int64 - ASNLimitPercentURL string - // BlocklistURLs are where the blocklists are fetched from - // (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is - // fetched again BlocklistRefresh after it was last fetched or tried - // (SWW_BLOCKLIST_REFRESH), which is never less than an hour. - // BlocklistAction is what is done with a client a blocklist lists - // (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit, - // BlocklistLimitPercent is the percentage of every limit it gets. - BlocklistURLs []string - BlocklistRefresh time.Duration - BlocklistAction string - BlocklistLimitPercent int64 - // DNSBLZones are the DNSBL zones clients are asked about - // (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or - // the host's resolver while that is the zero AddrPort. - // AbuseIPDBKey is the key of the AbuseIPDB account clients are checked - // with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score - // of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and - // at most AbuseIPDBDailyBudget checks are made a day - // (SWW_ABUSEIPDB_DAILY_BUDGET). - // ReputationAction is what is done with a client a zone's verdict lists, - // or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log; - // for limit, ReputationLimitPercent is the percentage of every limit it - // gets. A verdict or a score is used for ReputationCacheTTL after it was - // fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take - // ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off. - DNSBLZones []string - DNSBLResolver netip.AddrPort - AbuseIPDBKey string - AbuseIPDBMinScore int64 - AbuseIPDBDailyBudget int - ReputationAction string - ReputationLimitPercent int64 - ReputationCacheTTL time.Duration - ReputationTimeout time.Duration - // CrowdSecDecisionsURL is where the decision list of the CrowdSec - // engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from: - // that URL with v1/decisions added to its path, "" while it is unset and - // none is. CrowdSecKey is the key the engine is asked with - // (SWW_CROWDSEC_LAPI_KEY). - CrowdSecDecisionsURL string - CrowdSecKey string - // BanResponse is the status a refused client is answered with, 403 - // or 429, or 0 to close the connection without an answer - // (SWW_BAN_RESPONSE). It answers a banned client, a request that - // breaks a rate limit or matches a ban rule, SWW_DENY_NETS, - // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY. - BanResponse int - // LimitBanDuration is the ban for a first broken rate limit - // (SWW_LIMIT_BAN_DURATION). A limit broken again within - // LimitBanRepeatWindow after the last ban ended bans for three times - // as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that - // would be longer than MaxBanDuration is permanent instead - // (SWW_MAX_BAN_DURATION). None of them can be off. - LimitBanDuration time.Duration - LimitBanRepeatWindow time.Duration - MaxBanDuration time.Duration - // AttackBanDuration is the ban for a first clear sign of attack - // (SWW_ATTACK_BAN_DURATION). It cannot be off. - AttackBanDuration time.Duration - // MaxBans is the most bans held (SWW_MAX_BANS). - MaxBans int - // BanScopeV4Prefix is the length of the netblock around an IPv4 - // client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX). - 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 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 - // LogLevel is the least severe of the process's own messages that are - // written (SWW_LOG_LEVEL). It holds back no request log line. - LogLevel slog.Level - // AdminToken is the bearer token an admin sends for the ban endpoints - // and /_sww/clients/ (SWW_ADMIN_TOKEN), "" while it is - // unset and they are off. - AdminToken string - // MetricsToken is the bearer token a scraper sends for the metrics - // (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off. - // MetricsTopN is how many AS numbers and how many countries get series - // of their own in the metrics (SWW_METRICS_TOP_N). - MetricsToken string - MetricsTopN int - // RulesDir is the directory of the rule files (SWW_RULES_DIR), read - // unless RulesEnabled is false (SWW_RULES_ENABLED). - RulesDir string - RulesEnabled bool - // WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff, - // WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia - // level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and - // WAFAnomalyThreshold the anomaly score at which a request is a match - // (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules - // are the ids of its rules switched off (SWW_WAF_DISABLED_RULES), - // WAFExemptPaths the path prefixes it does not inspect - // (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body - // it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. - // CoreRuleSetExclusionsFile is the path of the file of its exclusions - // (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. - WAFMode string - WAFParanoiaLevel int - WAFAnomalyThreshold int - WAFDisabledRules []int - WAFExemptPaths []string - WAFBodyLimit int64 - CoreRuleSetExclusionsFile string - // TrapPaths are the paths a request for which is a clear sign of - // attack (SWW_TRAP_PATHS), each starting with / and without a ?. - TrapPaths []string - // ErrorBurstThreshold is the most requests of a client within a minute - // that waf may refuse after a rule file or Core Rule Set match; one - // more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off. - ErrorBurstThreshold int64 - // LogRemoteURL is where every line on stdout is also sent - // (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent. - // LogRemoteTLSCAs are the certificates a syslog+tls endpoint's - // certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while - // it is unset and the host's own are used. LogRemoteBuffer is the most - // lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER). - // LogRemoteFacility is the number of the syslog facility - // (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME - // (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records - // the lines are sent in. - LogRemoteURL *url.URL - LogRemoteTLSCAs *x509.CertPool - LogRemoteBuffer int - LogRemoteFacility int - LogRemoteAppName string - // AlertWebhookURL is where each alert is posted as JSON - // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders - // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). - // AlertSlackWebhookURL is the Slack incoming webhook each alert is - // posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and - // AlertNtfyURL the ntfy topic each is published to - // (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, - // unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN). - // With none of the three URLs set, no alert is sent. AlertEvents are - // the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an - // alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and - // so is an alert past AlertMaxPerHour in an hour, for the hour's - // summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both. - AlertWebhookURL *url.URL - AlertWebhookHeaders http.Header - AlertSlackWebhookURL *url.URL - AlertNtfyURL *url.URL - AlertNtfyToken string - AlertEvents []string - AlertCooldown time.Duration - AlertMaxPerHour int - // The anomaly thresholds, which only raise alerts: the most requests - // and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*), - // per netblock around a client (SWW_ANOMALY_NET_*), per AS number - // (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*) - // and per named netblock (SWW_WATCH_*), each 0 while it is off. - // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the - // netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and - // SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks - // (SWW_WATCH_NETS). - AnomalyClient anomaly.Thresholds - AnomalyNet anomaly.Thresholds - AnomalyASN anomaly.Thresholds - AnomalyTotal anomaly.Thresholds - AnomalyWatch anomaly.Thresholds - AnomalyNetV4Prefix int - AnomalyNetV6Prefix int - WatchNets []anomaly.NamedNetblock - - // settings are the values read, as given or by default, and the - // files they were read from, for the log line at start. - settings []slog.Attr -} - // off is the value that switches a timeout, a size limit or a rate limit // off. const off = "off" -// The values of SWW_WAF_MODE. const ( - // WAFModeOff runs no request through the Core Rule Set. - WAFModeOff = off - // WAFModeDetect logs and alerts a match, and refuses nothing. - WAFModeDetect = "detect" - // WAFModeBlock refuses a match with 403. - WAFModeBlock = "block" -) - -// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the -// lookup database, the file SWW_LOOKUP_DB_PATH names. -const fileSource = "file" - -const ( - day = 24 * time.Hour kibibyte = 1 << 10 mebibyte = 1 << 20 gibibyte = 1 << 30 - ipv4Bits = 32 - ipv6Bits = 128 - // minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the - // netblock a provider is usually given: a shorter one would make one - // client of the customers of several providers. - minIPv6GroupPrefix = 32 - // minTokenLength is the fewest characters a token may have. - minTokenLength = 32 - // maxParanoiaLevel is the Core Rule Set's highest paranoia level. - maxParanoiaLevel = 4 - // firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set - // keeps for the rules that set it up, which sww's own rules - // have too (see internal/coreruleset). Switching one off would undo a change - // that no setting undoes. - firstSetupRuleID = 900000 - lastSetupRuleID = 900999 - // masked is what the log shows for a token that is set, and in place of - // a secret in another setting. - masked = "********" // defaultListenAddr is the default of SWW_LISTEN_ADDR and - // SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL. - defaultListenAddr = ":8080" - defaultUpstreamURL = "http://127.0.0.1:8081" + // SWRP_LISTEN_ADDR. + defaultListenAddr = ":8080" ) var ( - errNotDuration = errors.New( - "is not a duration such as 90s, 15m or 7d, or off") errNotSize = errors.New( "is not a size such as 512K, 100M or 5G, or off") - errNotCount = errors.New( - "is not a whole number of requests such as 1000, or off") errNotPositive = errors.New("must be more than zero, or off") errEmptyItem = errors.New("has an empty item in its list") errNotNetblock = errors.New( "is not a netblock such as 10.0.0.0/8, or an address") errNotListenAddr = errors.New( "is not an address to listen on, such as :8080") - errNotUpstreamURL = errors.New( - "is not a URL with only a scheme, a host and an optional port, " + - "such as http://127.0.0.1:8081") - errNotCountry = errors.New( - "is not a two-letter country code such as de or kp") - errNotHeaderName = errors.New( - "is not a header name such as accept-language") - errHeaderTakenOut = errors.New( - "is taken out of every request by Go's HTTP server, so it can never " + - "be logged") - errOnBothLists = errors.New("is in SWW_TAG_DENY too") - errNotLookupSource = errors.New("is not geojs, file or off") - errNeedsLookups = errors.New("it needs each client looked up") - errNeedsDBPath = errors.New("it names the file to look clients up in") - errDBPathUnused = errors.New("only file reads it") - errOver64K = errors.New( - "is more than 64K, the most waf gets of a body") - errNotCommand = errors.New("is not rproxy or waf") - errNotDurationAboveZero = errors.New( - "is not a duration above zero, such as 1h or 7d") - errNotNumberAboveZero = errors.New( - "is not a whole number above zero, such as 5000") - errNotBanResponse = errors.New("is not 403, 429 or close") - errNotV4Prefix = errors.New( - "is not the length of an IPv4 netblock, from 0 to 32, such as 24") - errNotV6Prefix = errors.New( - "is not the length of an IPv6 netblock, from 0 to 128, such as 48") - errNotIPv6GroupPrefix = errors.New( - "is not the length of an IPv6 netblock, from 32 to 128, such as 64") - errNotLogLevel = errors.New("is not debug, info, warn or error") - errNotNamedNetblock = errors.New( - "is not a name, = and a netblock, such as office=203.0.113.0/24") - errNotAbsolutePath = errors.New( - "is not an absolute path, such as /var/lib/sww") - errShortToken = errors.New("is shorter than 32 characters") - errNotMode = errors.New("is not enforce or observe") - errNotBytesCount = errors.New("is not response, request or both") - errNotPathPrefix = errors.New( - "is not a path prefix starting with /, such as /assets/") - errNotTrapPath = errors.New( - "is not a path starting with / and without a ?, such as /wp-login.php", - ) - errNotWAFMode = errors.New("is not off, detect or block") - errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") - errNotRuleID = errors.New( - "is not the id of a Core Rule Set rule, a whole number such as 942100", - ) - errSetupRuleID = errors.New( - "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + - "up and of sww's own, which cannot be switched off") - errNotBoolean = errors.New("is not true or false") - errNotLogRemoteURL = errors.New( - "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + - "and nothing more, such as syslog+tls://logs.example:6514", - ) - errNoCertificate = errors.New("holds no PEM certificate") - errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") - errNotAppName = errors.New( - "is not 1 to 48 printable ASCII characters without a space, such as blog") - errSetTwice = errors.New("set only one of them") - errNotWebhookURL = errors.New( - "is not an http or https URL without a user or a fragment, " + - "such as https://alerts.example/sww") - errNotWebhookHeader = errors.New( - "is not a header name followed by : and the header's value, " + - "such as Authorization:Bearer ") - errControlCharacter = errors.New( - "holds a control character, such as the carriage return of a Windows line end", - ) - errNotAlertEvent = errors.New( - "is not ban, permanent_ban, core_rule_set_match, anomaly, reputation_hit, " + - "source_failure or file_error") - errNotNumberOrOff = errors.New( - "is not a whole number above zero, such as 60, or off", - ) - errNotUTF8 = errors.New("is not valid UTF-8") - errNotASN = errors.New("is not an AS number such as AS64496") - errNotPercentItem = errors.New( - "is not a code, : and a percentage, such as AS64496:50 or cn:25") - errNotPercent = errors.New( - "is not a percentage, a whole number from 0 to 100", - ) - errListedTwice = errors.New("is listed twice") - errNotListURL = errors.New( - "is not an http or https URL without a user or a fragment, " + - "such as https://www.spamhaus.org/drop/drop.txt") - errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too") - errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") - errNotAction = errors.New( - "is not deny, limit: such as limit:25, or log") - errNotZone = errors.New( - "is not a DNS zone such as dnsbl.dronebl.org", - ) - errZoneTooLong = errors.New( - "is longer than 189 characters, too long for the " + - "names IPv6 clients are asked about by", - ) - errNotResolver = errors.New( - "is not an IP address with an optional port, " + - "such as 192.0.2.53 or [2001:db8::53]:5353", - ) - errNotLAPIURL = errors.New( - "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( - "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") - errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " + - "SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too") - errNotTagList = errors.New( - "is not a name, = and an http or https URL, such as " + - "torexit=https://example.com/tor.txt") - errNotTagPercentItem = errors.New( - "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") - 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( - "cannot be used: a CrowdSec decision refuses the request first") - errNoRequestGetsTag = errors.New( - "is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " + - "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + - "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") + errNotCommand = errors.New("is not rproxy or waf") + errSetTwice = errors.New("set only one of them") ) -// FromEnvironment reads the settings with lookupEnv, normally -// os.LookupEnv. A setting may instead be given as a file: the variable -// named by the setting's name with _FILE added names the file, which is -// read now (see lookup). A setting that is not set takes its default. A -// setting that is set but invalid is an error that names it. -// -//nolint:funlen // one line for each setting, a list that grows with them -func FromEnvironment( - lookupEnv func(string) (string, bool), -) (*Config, error) { - env := &environment{lookupEnv: lookupEnv} - cfg := &Config{ - ListenAddr: env.address("SWW_LISTEN_ADDR"), - InstanceName: env.instanceName(), - Observe: env.observe("SWW_MODE", "enforce"), - IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"), - MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"), - AllowNets: env.netblocks("SWW_ALLOW_NETS", ""), - RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""), - DenyNets: env.netblocks("SWW_DENY_NETS", ""), - RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"), - RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"), - RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"), - RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""), - BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"), - BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"), - BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"), - BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"), - LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"), - LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), - LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"), - AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), - ExclusivelyAllowedCountries: env.countries( - "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), - 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"), - ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"), - BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"), - BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"), - DNSBLZones: env.zones("SWW_DNSBL_ZONES"), - DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"), - AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"), - AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"), - AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"), - ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"), - ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"), - CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"), - CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"), - BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"), - LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"), - LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"), - MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"), - AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"), - MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"), - BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"), - 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"), - AdminToken: env.token("SWW_ADMIN_TOKEN"), - MetricsToken: env.token("SWW_METRICS_TOKEN"), - MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"), - RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"), - RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"), - WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock), - WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"), - WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"), - WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES", - "920340,920420,920440,920640,930130,930140"), - WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""), - WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), - CoreRuleSetExclusionsFile: env.filePath( - "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), - TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), - ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), - LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), - LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"), - LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"), - LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"), - AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"), - AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"), - AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"), - AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"), - AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"), - AlertEvents: env.alertEvents("SWW_ALERT_EVENTS", - strings.Join(alerts.Events(), ",")), - AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"), - AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"), - AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"), - AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"), - AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"), - AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"), - AnomalyWatch: env.thresholds("SWW_WATCH_"), - AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"), - AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"), - WatchNets: env.namedNetblocks("SWW_WATCH_NETS"), - } - - cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME", - cfg.InstanceName, cfg.LogRemoteURL != nil) - cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action( - "SWW_BLOCKLIST_ACTION", "deny") - cfg.ReputationAction, cfg.ReputationLimitPercent = env.action( - "SWW_REPUTATION_ACTION", "limit:25") - - env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) - env.checkLookupDBPath(cfg) - env.checkCountriesAndLookups(cfg) - env.checkASNLimitPercentURL(cfg) - env.checkCrowdSec(cfg) - env.checkTagLists(cfg) - env.checkTags(cfg) - - if env.err != nil { - return nil, env.err - } - - cfg.settings = env.settings - - return cfg, nil -} - // Command reads only SWW_COMMAND, the command the image runs and its // health check checks: rproxy when it is unset or empty, or waf. It cannot // be given as a file. Any other value is an error that names the setting. @@ -646,36 +61,6 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) { } } -// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a -// file, as FromEnvironment does. The health check in waf's container needs -// no other setting, so it reads no other, nor a file that another names. -func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) { - env := &environment{lookupEnv: lookupEnv} - listenAddr := env.address("SWW_LISTEN_ADDR") - - return listenAddr, env.err -} - -// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a -// file, as FromEnvironment does, so that the line saying a setting is -// invalid carries it too. A file that cannot be read gives the default -// here, and FromEnvironment then stops the start over it. -func InstanceName(lookupEnv func(string) (string, bool)) string { - env := &environment{lookupEnv: lookupEnv} - - return env.instanceName() -} - -// privateRanges are the private address ranges, the default trusted -// proxies. -const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" - -// LogValue makes a Config log as each setting's name with the value it was -// given, or its default. -func (c *Config) LogValue() slog.Value { - return slog.GroupValue(c.settings...) -} - // environment is where FromEnvironment reads the settings: it notes each // value for the log, and keeps the first error. type environment struct { @@ -747,35 +132,6 @@ func (e *environment) address(name string) string { return address } -// appURL reads a setting that is the app's URL. -func (e *environment) appURL(name, defaultValue string) *url.URL { - upstream, err := parseUpstreamURL(e.value(name, defaultValue)) - e.check(name, err) - - return upstream -} - -// observe reads the setting that is the mode, enforce or observe, and -// reports whether it is observe. -func (e *environment) observe(name, defaultValue string) bool { - mode := e.value(name, defaultValue) - if mode != "enforce" && mode != "observe" { - e.check(name, fmt.Errorf("%q %w", mode, errNotMode)) - } - - return mode == "observe" -} - -// boolean reads a setting that is true or false. -func (e *environment) boolean(name, defaultValue string) bool { - value := e.value(name, defaultValue) - if value != "true" && value != "false" { - e.check(name, fmt.Errorf("%q %w", value, errNotBoolean)) - } - - return value == "true" -} - // netblocks reads a setting that is a list of netblocks. func (e *environment) netblocks(name, defaultValue string) []netip.Prefix { netblocks, err := parseNetblocks(e.value(name, defaultValue)) @@ -784,14 +140,6 @@ func (e *environment) netblocks(name, defaultValue string) []netip.Prefix { return netblocks } -// duration reads a setting that is a duration. -func (e *environment) duration(name, defaultValue string) time.Duration { - duration, err := parseDuration(e.value(name, defaultValue)) - e.check(name, err) - - return duration -} - // size reads a setting that is a number of bytes. func (e *environment) size(name, defaultValue string) int64 { size, err := parseSize(e.value(name, defaultValue)) @@ -800,798 +148,6 @@ func (e *environment) size(name, defaultValue string) int64 { return size } -// wafBodyLimit reads the setting that is the most of a request body the -// Core Rule Set reads. -func (e *environment) wafBodyLimit(name, defaultValue string) int64 { - limit, err := parseWAFBodyLimit(e.value(name, defaultValue)) - e.check(name, err) - - return limit -} - -// count reads a setting that is a number of requests. -func (e *environment) count(name, defaultValue string) int64 { - count, err := parseCount(e.value(name, defaultValue)) - e.check(name, err) - - return count -} - -// bytesCount reads the setting that is which body bytes count toward the -// byte limits: response, request or both. -func (e *environment) bytesCount(name, defaultValue string) string { - value := e.value(name, defaultValue) - if value != "response" && value != "request" && value != "both" { - e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount)) - } - - return value -} - -// pathPrefixes reads a setting that is a list of path prefixes. -func (e *environment) pathPrefixes(name, defaultValue string) []string { - prefixes, err := parsePathPrefixes(e.value(name, defaultValue)) - e.check(name, err) - - return prefixes -} - -// trapPaths reads the setting that is the list of trap paths. It is empty -// by default. -func (e *environment) trapPaths(name string) []string { - paths, err := parseTrapPaths(e.value(name, "")) - e.check(name, err) - - return paths -} - -// wafMode reads the setting that is what the Core Rule Set does: off, -// detect or block. -func (e *environment) wafMode(name, defaultValue string) string { - mode := e.value(name, defaultValue) - if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock { - e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode)) - } - - return mode -} - -// paranoiaLevel reads the setting that is the Core Rule Set's paranoia -// level, from 1 to 4. -func (e *environment) paranoiaLevel(name, defaultValue string) int { - value := e.value(name, defaultValue) - - level, err := strconv.Atoi(value) - if err != nil || level < 1 || level > maxParanoiaLevel { - e.check(name, fmt.Errorf("%q %w", value, errNotParanoiaLevel)) - } - - return level -} - -// ruleIDs reads the setting that is a list of the ids of Core Rule Set -// rules. -func (e *environment) ruleIDs(name, defaultValue string) []int { - ids, err := parseRuleIDs(e.value(name, defaultValue)) - e.check(name, err) - - return ids -} - -// countries reads a setting that is a list of countries. -func (e *environment) countries(name, defaultValue string) []string { - countries, err := parseCountries(e.value(name, defaultValue)) - e.check(name, err) - - return countries -} - -// percents reads a setting that is a list of AS numbers or countries, -// which parseCode reads, each with a percentage. It is empty by default. -func (e *environment) percents( - name string, parseCode func(string) (string, error), -) map[string]int64 { - percents, err := parsePercents(e.value(name, ""), parseCode) - e.check(name, err) - - return percents -} - -// tagLists reads SWW_TAG_LISTS, which is empty by default. -func (e *environment) tagLists(name string) []TagList { - lists, err := parseTagLists(e.value(name, "")) - e.check(name, err) - - return lists -} - -// tags reads a setting that is a list of tags. It is empty by default. -func (e *environment) tags(name string) []string { - tags, err := parseTags(e.value(name, "")) - e.check(name, err) - - return tags -} - -// tagPercents reads a setting that is a list of tags, each with a -// percentage. It is empty by default. -func (e *environment) tagPercents(name string) map[string]int64 { - percents, err := parseTagPercents(e.value(name, "")) - e.check(name, err) - - return percents -} - -// percent reads a setting that is a percentage, from 0 to 100. -func (e *environment) percent(name, defaultValue string) int64 { - percent, err := ParsePercent(e.value(name, defaultValue)) - e.check(name, err) - - return percent -} - -// listURL reads a setting that is the URL a list is fetched from, "" while -// it is unset or empty. -func (e *environment) listURL(name string) string { - value := e.value(name, "") - if value != "" && !isListURL(value) { - e.check(name, fmt.Errorf("%q %w", value, errNotListURL)) - } - - return value -} - -// listURLs reads a setting that is a list of the URLs lists are fetched -// from. It is empty by default. -func (e *environment) listURLs(name string) []string { - urls, err := parseListURLs(e.value(name, "")) - e.check(name, err) - - return urls -} - -// refresh reads the setting that is how long after a list was last -// fetched or tried it is fetched again: a duration of an hour or more, -// since the Spamhaus lists may be fetched no more often, which cannot be -// off. -func (e *environment) refresh(name, defaultValue string) time.Duration { - value := e.value(name, defaultValue) - - duration, err := parseDuration(value) - if err != nil || duration < time.Hour { - e.check(name, fmt.Errorf("%q %w", value, errNotAnHourOrMore)) - } - - return duration -} - -// action reads a setting that is what is done with a client a blocklist -// or a DNSBL zone lists: deny, log, or limit:, which it returns -// as limit and the percentage. -func (e *environment) action(name, defaultValue string) (string, int64) { - value := e.value(name, defaultValue) - if value == "deny" || value == "log" { - return value, 0 - } - - percentText, isLimit := strings.CutPrefix(value, "limit:") - - percent, err := ParsePercent(percentText) - if !isLimit || err != nil { - e.check(name, fmt.Errorf("%q %w", value, errNotAction)) - } - - return "limit", percent -} - -// zones reads the setting that is the list of DNSBL zones. It is empty by -// default. The log shows each zone with its key masked, as MaskZoneKey -// masks it. -func (e *environment) zones(name string) []string { - value, _ := e.lookup(name) - zones, err := parseZones(value) - e.check(name, err) - - logged := make([]string, len(zones)) - for i, zone := range zones { - logged[i] = MaskZoneKey(zone) - } - - e.settings = append( - e.settings, - slog.String(name, strings.Join(logged, ",")), - ) - - return zones -} - -// resolver reads the setting that is the resolver the DNSBL zones are -// asked through, the zero AddrPort while it is unset or empty. -func (e *environment) resolver(name string) netip.AddrPort { - resolver, err := parseResolver(e.value(name, "")) - e.check(name, err) - - return resolver -} - -// crowdSecDecisionsURL reads the setting that is the URL of the CrowdSec -// engine's local API, such as http://172.17.0.1:8080, and returns the URL -// its decision list is fetched from, that URL with v1/decisions added to -// its path, "" while it is unset or empty. -func (e *environment) crowdSecDecisionsURL(name string) string { - value := e.value(name, "") - if value == "" { - return "" - } - - lapi, err := url.Parse(value) - if err != nil || !isHTTPURL(lapi) { - e.check(name, fmt.Errorf("%q %w", value, errNotLAPIURL)) - - return "" - } - - 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 { - source := e.value(name, defaultValue) - if source != "geojs" && source != fileSource && source != off { - e.check(name, fmt.Errorf("%q %w", source, errNotLookupSource)) - } - - return source -} - -// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without -// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source: -// one source at a time. -func (e *environment) checkLookupDBPath(cfg *Config) { - switch { - case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": - e.check("SWW_LOOKUP_SOURCE", fmt.Errorf( - "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) - case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": - e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf( - "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) - } -} - -// checkCountriesAndLookups refuses a country in -// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and, -// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked -// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name -// an asn= or country= tag, the biased thresholds, -// SWW_ASN_LIMIT_PERCENT_URL among them, of which -// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a -// limit, and the anomaly thresholds per AS number. -func (e *environment) checkCountriesAndLookups(cfg *Config) { - for _, country := range cfg.ExclusivelyAllowedCountries { - if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) { - e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", - fmt.Errorf("%q %w", country, errOnBothLists)) - } - } - - if cfg.LookupSource != off { - 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, 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}, - {"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, - {"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, - {"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, - {"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, - {"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, - } { - if setting.set { - e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", - errNeedsLookups)) - } - } -} - -// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a -// blocklist too: the file at a URL is fetched as one list or the other. -func (e *environment) checkASNLimitPercentURL(cfg *Config) { - if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) { - e.check("SWW_ASN_LIMIT_PERCENT_URL", - fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs)) - } -} - -// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without -// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list -// that is fetched as another list too. -func (e *environment) checkCrowdSec(cfg *Config) { - decisionsURL := cfg.CrowdSecDecisionsURL - - switch { - case decisionsURL != "" && cfg.CrowdSecKey == "": - e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) - case decisionsURL == "" && cfg.CrowdSecKey != "": - e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf( - "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) - case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || - decisionsURL == cfg.ASNLimitPercentURL): - e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", - decisionsURL, errAnotherList)) - } -} - -// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as -// another list too: the file at a URL is read as one kind of list. -func (e *environment) checkTagLists(cfg *Config) { - for _, list := range cfg.TagLists { - if slices.Contains(cfg.BlocklistURLs, list.URL) || - list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL { - e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise)) - } - } -} - -// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would -// never apply: crowdsec, whose request is refused before tags are acted on, and -// any tag these settings cannot give a request. -func (e *environment) checkTags(cfg *Config) { - for _, tag := range cfg.TagDeny { - e.check("SWW_TAG_DENY", checkTag(cfg, tag)) - } - - for tag := range cfg.TagLimitPercent { - e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag)) - } -} - -// checkTag returns an error when tag would never apply, as checkTags says. -func checkTag(cfg *Config, tag string) error { - if tag == CrowdSecTag { - return fmt.Errorf("%q %w", tag, errCrowdSecTag) - } - - if !requestCanGetTag(cfg, tag) { - return fmt.Errorf("%q %w", MaskZoneKey(tag), errNoRequestGetsTag) - } - - return nil -} - -// requestCanGetTag reports whether anything cfg sets up can give a request tag. -func requestCanGetTag(cfg *Config, tag string) bool { - isListName := func(list TagList) bool { return list.Name == tag } - if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) { - return true - } - - if tag == ExploitSignatureTag { - coreRuleSetOn := cfg.WAFMode != WAFModeOff && cfg.WAFAnomalyThreshold != 0 - - return cfg.RulesEnabled || coreRuleSetOn - } - - if tag == AbuseIPDBTag { - 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 { - return strings.ToLower(MaskZoneKey(each)) == zone - }) -} - -// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT -// names an asn= or country= tag. -func (c *Config) TagsNeedLookup() bool { - return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) -} - -// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn= -// or country= tag. -func tagLimitNeedsLookups(cfg *Config) bool { - for tag := range cfg.TagLimitPercent { - if isLookupTag(tag) { - return true - } - } - - return false -} - -// isLookupTag reports whether tag is an asn= or country= tag. -func isLookupTag(tag string) bool { - return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix) -} - -// headerNames reads a setting that is a list of header names, and -// returns them in lower case. -func (e *environment) headerNames(name, defaultValue string) []string { - headers, err := parseHeaderNames(e.value(name, defaultValue)) - e.check(name, err) - - return headers -} - -// durationNotOff reads a setting that is a duration and, unlike a -// timeout, cannot be off. -func (e *environment) durationNotOff( - name, defaultValue string, -) time.Duration { - duration, err := ParseDurationNotOff(e.value(name, defaultValue)) - e.check(name, err) - - return duration -} - -// numberNotOff reads a setting that is a whole number above zero, which -// cannot be off. -func (e *environment) numberNotOff(name, defaultValue string) int { - number, err := parseNumberNotOff(e.value(name, defaultValue)) - e.check(name, err) - - return number -} - -// banResponse reads a setting that is how a refused client is answered. -func (e *environment) banResponse(name, defaultValue string) int { - status, err := parseBanResponse(e.value(name, defaultValue)) - e.check(name, err) - - return status -} - -// v4Prefix reads a setting that is the length of an IPv4 netblock. -func (e *environment) v4Prefix(name, defaultValue string) int { - length, err := parseV4Prefix(e.value(name, defaultValue)) - e.check(name, err) - - return length -} - -// v6Prefix reads a setting that is the length of an IPv6 netblock. -func (e *environment) v6Prefix(name, defaultValue string) int { - length, err := parseV6Prefix(e.value(name, defaultValue)) - e.check(name, err) - - return length -} - -// ipv6GroupPrefix reads the setting that is the length of the IPv6 -// netblock that is one client, from minIPv6GroupPrefix to 128. -func (e *environment) ipv6GroupPrefix(name, defaultValue string) int { - value := e.value(name, defaultValue) - - length, err := strconv.Atoi(value) - if err != nil || length < minIPv6GroupPrefix || length > ipv6Bits { - e.check(name, fmt.Errorf("%q %w", value, errNotIPv6GroupPrefix)) - } - - return length -} - -// logLevel reads the setting that is the least severe of the process's -// own messages that are written: debug, info, warn or error. -func (e *environment) logLevel(name, defaultValue string) slog.Level { - value := e.value(name, defaultValue) - - level, known := map[string]slog.Level{ - "debug": slog.LevelDebug, - "info": slog.LevelInfo, - "warn": slog.LevelWarn, - "error": slog.LevelError, - }[value] - if !known { - e.check(name, fmt.Errorf("%q %w", value, errNotLogLevel)) - } - - return level -} - -// thresholds reads the four anomaly thresholds whose settings' names -// start with prefix: requests and bytes per minute and per hour. Each is -// off by default. -func (e *environment) thresholds(prefix string) anomaly.Thresholds { - return anomaly.Thresholds{ - RequestsPerMinute: e.count(prefix+"REQUESTS_PER_MINUTE", off), - RequestsPerHour: e.count(prefix+"REQUESTS_PER_HOUR", off), - BytesPerMinute: e.size(prefix+"BYTES_PER_MINUTE", off), - BytesPerHour: e.size(prefix+"BYTES_PER_HOUR", off), - } -} - -// namedNetblocks reads a setting that is a list of named netblocks. It is -// empty by default. -func (e *environment) namedNetblocks(name string) []anomaly.NamedNetblock { - named, err := parseNamedNetblocks(e.value(name, "")) - e.check(name, err) - - return named -} - -// absolutePath reads a setting that is an absolute path. -func (e *environment) absolutePath(name, defaultValue string) string { - path := e.value(name, defaultValue) - if !filepath.IsAbs(path) { - e.check(name, fmt.Errorf("%q %w", path, errNotAbsolutePath)) - } - - return path -} - -// token reads a setting that is a bearer token. Unset, it is "", which -// switches off what it guards; set, it must be at least minTokenLength -// characters. Neither the log nor an error shows its value. -func (e *environment) token(name string) string { - value, set := e.lookup(name) - if !set { - e.settings = append(e.settings, slog.String(name, "")) - - return "" - } - - e.settings = append(e.settings, slog.String(name, masked)) - - if utf8.RuneCountInString(value) < minTokenLength { - e.check(name, errShortToken) - } - - return value -} - -// logRemoteURL reads the setting that is where every log line is also -// sent. Unset or empty, it is nil, and nothing is sent. -func (e *environment) logRemoteURL(name string) *url.URL { - value := e.value(name, "") - if value == "" { - return nil - } - - remote, err := parseLogRemoteURL(value) - e.check(name, err) - - return remote -} - -// filePath reads a setting that is the path of a file, "" while it is -// unset. Its value names a file already, so, unlike the other settings, -// it has no _FILE form. Whoever uses the file reads it. -func (e *environment) filePath(name string) string { - path, _ := e.lookupEnv(name) - e.settings = append(e.settings, slog.String(name, path)) - - return path -} - -// certificates reads a setting that is the path of a file of PEM -// certificates. Unset or empty, it is nil. Its value names a file -// already, so, unlike the other settings, it has no _FILE form. -func (e *environment) certificates(name string) *x509.CertPool { - path, _ := e.lookupEnv(name) - e.settings = append(e.settings, slog.String(name, path)) - - if path == "" { - return nil - } - - pem, err := os.ReadFile(path) //nolint:gosec // a file the admin names - if err != nil { - e.check(name, fmt.Errorf("cannot be read: %w", err)) - - return nil - } - - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pem) { - e.check(name, fmt.Errorf("%q %w", path, errNoCertificate)) - - return nil - } - - return pool -} - -// facility reads a setting that is a syslog facility, and returns its -// number. -func (e *environment) facility(name, defaultValue string) int { - number, err := parseFacility(e.value(name, defaultValue)) - e.check(name, err) - - return number -} - -// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It -// must be valid UTF-8: the metrics library panics on a label that is not. -func (e *environment) instanceName() string { - hostname, _ := os.Hostname() // "" when the host has no name to give - - value := e.value("SWW_INSTANCE_NAME", hostname) - if !utf8.ValidString(value) { - e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) - } - - return value -} - -// appName reads the setting that is the APP-NAME of the records the log -// lines are sent in, by default the instance name. Its value is checked -// when it is set, and, while lines are sent, when it is the instance name. -func (e *environment) appName( - name, instanceName string, - sending bool, -) string { - value, set := e.lookup(name) - if !set { - value = instanceName - } - - e.settings = append(e.settings, slog.String(name, value)) - - switch { - case isAppName(value): - case set: - e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) - case sending: - e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", - value, errNotAppName)) - } - - return value -} - -// checkInstanceNameForNtfy refuses an instance name that holds a control -// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent -// the instance name in a header, which cannot hold one. -func (e *environment) checkInstanceNameForNtfy( - instanceName string, - ntfySet bool, -) { - if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { - e.check("SWW_INSTANCE_NAME", fmt.Errorf( - "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", - instanceName, errControlCharacter)) - } -} - -// webhookURL reads a setting that is a URL each alert is posted to: -// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or -// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted -// there. The log shows ******** in place of its path and query, and an -// error shows none of it, since a webhook or an ntfy topic can carry its -// secret there. -func (e *environment) webhookURL(name string) *url.URL { - value, _ := e.lookup(name) - webhook, logged, err := parseWebhookURL(value) - e.settings = append(e.settings, slog.String(name, logged)) - e.check(name, err) - - return webhook -} - -// webhookHeaders reads the setting that is the headers sent with each -// alert. The log shows each header's value as ********, since a header -// such as Authorization carries a secret. -func (e *environment) webhookHeaders(name string) http.Header { - value, _ := e.lookup(name) - headers, logged, err := parseWebhookHeaders(value) - e.settings = append(e.settings, slog.String(name, logged)) - e.check(name, err) - - return headers -} - -// secret reads a setting that is a secret another service gave, such as -// an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in -// a header, which cannot hold a control character, so one in it is an -// error. The log shows ******** in place of a value that is not empty, and -// an error shows none of it. -func (e *environment) secret(name string) string { - value, _ := e.lookup(name) - - logged := "" - if value != "" { - logged = masked - } - - e.settings = append(e.settings, slog.String(name, logged)) - - if strings.ContainsFunc(value, unicode.IsControl) { - e.check(name, errControlCharacter) - } - - return value -} - -// alertEvents reads the setting that is the events alerts are sent for. -func (e *environment) alertEvents(name, defaultValue string) []string { - events, err := parseAlertEvents(e.value(name, defaultValue)) - e.check(name, err) - - return events -} - -// numberOrOff reads a setting that is a whole number above zero, or off, -// which is 0. -func (e *environment) numberOrOff(name, defaultValue string) int { - number, err := parseNumberOrOff(e.value(name, defaultValue)) - e.check(name, err) - - return number -} - -// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a -// whole number of days such as 7d, or off. -func parseDuration(value string) (time.Duration, error) { - if value == off { - return 0, nil - } - - duration, err := durationOrDays(value) - if err != nil { - return 0, fmt.Errorf("%q %w", value, errNotDuration) - } - - if duration <= 0 { - return 0, fmt.Errorf("%q %w", value, errNotPositive) - } - - return duration, nil -} - -// durationOrDays reads Go's duration syntax, or a whole number of days. -func durationOrDays(value string) (time.Duration, error) { - days, isDays := strings.CutSuffix(value, "d") - if !isDays { - return time.ParseDuration(value) - } - - n, err := strconv.ParseInt(days, 10, 64) - if err != nil || n < 0 || n > math.MaxInt64/int64(day) { - return 0, errNotDuration - } - - return time.Duration(n) * day, nil -} - // parseSize reads a number of bytes with an optional K, M or G suffix, in // powers of 1024 (1K is 1024 bytes), or off. func parseSize(value string) (int64, error) { @@ -1613,22 +169,6 @@ func parseSize(value string) (int64, error) { return n * unit, nil } -// parseWAFBodyLimit reads the most of a request body the Core Rule Set -// reads: a size as parseSize reads it, or off, but at most 64K, since waf -// gets no more of a body than that. -func parseWAFBodyLimit(value string) (int64, error) { - limit, err := parseSize(value) - if err != nil { - return 0, err - } - - if limit > 64*kibibyte { - return 0, fmt.Errorf("%q %w", value, errOver64K) - } - - return limit, nil -} - // splitUnit splits a size into its number and the bytes its suffix // stands for. func splitUnit(value string) (string, int64) { @@ -1644,80 +184,6 @@ func splitUnit(value string) (string, int64) { } } -// parseCount reads a whole number of requests, or off. -func parseCount(value string) (int64, error) { - if value == off { - return 0, nil - } - - n, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return 0, fmt.Errorf("%q %w", value, errNotCount) - } - - if n <= 0 { - return 0, fmt.Errorf("%q %w", value, errNotPositive) - } - - return n, nil -} - -// ParseDurationNotOff reads a duration above zero, as parseDuration does, -// but not off. The ban endpoint reads the duration of a ban with it too. -func ParseDurationNotOff(value string) (time.Duration, error) { - duration, err := parseDuration(value) - if err != nil || duration == 0 { - return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero) - } - - return duration, nil -} - -// parseNumberNotOff reads a whole number above zero. -func parseNumberNotOff(value string) (int, error) { - n, err := strconv.Atoi(value) - if err != nil || n <= 0 { - return 0, fmt.Errorf("%q %w", value, errNotNumberAboveZero) - } - - return n, nil -} - -// parseBanResponse reads how a refused client is answered: 403, 429, or -// close, which is 0. -func parseBanResponse(value string) (int, error) { - switch value { - case "403": - return http.StatusForbidden, nil - case "429": - return http.StatusTooManyRequests, nil - case "close": - return 0, nil - default: - return 0, fmt.Errorf("%q %w", value, errNotBanResponse) - } -} - -// parseV4Prefix reads the length of an IPv4 netblock, from 0 to 32. -func parseV4Prefix(value string) (int, error) { - n, err := strconv.Atoi(value) - if err != nil || n < 0 || n > ipv4Bits { - return 0, fmt.Errorf("%q %w", value, errNotV4Prefix) - } - - return n, nil -} - -// parseV6Prefix reads the length of an IPv6 netblock, from 0 to 128. -func parseV6Prefix(value string) (int, error) { - n, err := strconv.Atoi(value) - if err != nil || n < 0 || n > ipv6Bits { - return 0, fmt.Errorf("%q %w", value, errNotV6Prefix) - } - - return n, nil -} - // parseList splits a comma-separated list and trims the spaces around // each item. An empty value is an empty list. func parseList(value string) ([]string, error) { @@ -1778,459 +244,6 @@ func ParseNetblock(value string) (netip.Prefix, error) { return netip.PrefixFrom(addr, addr.BitLen()), nil } -// parseNamedNetblocks reads a comma-separated list of named netblocks, -// each a name, = and a netblock, such as office=203.0.113.0/24. An empty -// value is an empty list. A name listed twice is an error. -func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - named := make([]anomaly.NamedNetblock, 0, len(items)) - - for _, item := range items { - name, netblockText, found := strings.Cut(item, "=") - name = strings.TrimSpace(name) - - if !found || name == "" { - return nil, fmt.Errorf("%q %w", item, errNotNamedNetblock) - } - - netblock, err := ParseNetblock(strings.TrimSpace(netblockText)) - if err != nil { - return nil, err - } - - if slices.ContainsFunc(named, func(n anomaly.NamedNetblock) bool { - return n.Name == name - }) { - return nil, fmt.Errorf("%q %w", name, errListedTwice) - } - - named = append( - named, - anomaly.NamedNetblock{Name: name, Netblock: netblock}, - ) - } - - return named, nil -} - -// parsePathPrefixes reads a comma-separated list of path prefixes, each -// starting with /. -func parsePathPrefixes(value string) ([]string, error) { - prefixes, err := parseList(value) - if err != nil { - return nil, err - } - - for _, prefix := range prefixes { - if !strings.HasPrefix(prefix, "/") { - return nil, fmt.Errorf("%q %w", prefix, errNotPathPrefix) - } - } - - return prefixes, nil -} - -// parseTrapPaths reads a comma-separated list of trap paths. Each is -// matched against a request's path as a path rule is, without the query, -// so a path that does not start with / would never match, and one that -// holds a ? is written with a query. -func parseTrapPaths(value string) ([]string, error) { - paths, err := parseList(value) - if err != nil { - return nil, err - } - - for _, path := range paths { - if !strings.HasPrefix(path, "/") || strings.Contains(path, "?") { - return nil, fmt.Errorf("%q %w", path, errNotTrapPath) - } - } - - return paths, nil -} - -// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set -// rules, each a whole number above zero and outside firstSetupRuleID to -// lastSetupRuleID. -func parseRuleIDs(value string) ([]int, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - ids := make([]int, len(items)) - - for i, item := range items { - ids[i], err = strconv.Atoi(item) - if err != nil || ids[i] <= 0 { - return nil, fmt.Errorf("%q %w", item, errNotRuleID) - } - - if ids[i] >= firstSetupRuleID && ids[i] <= lastSetupRuleID { - return nil, fmt.Errorf("%q %w", item, errSetupRuleID) - } - } - - return ids, nil -} - -// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK, -// the code in common use for Kosovo. golang.org/x/text/language cannot -// check them: it also takes withdrawn codes such as su, and reserved ones -// such as ac, as countries. -const countryCodes = ` -AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ -BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ -CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ -DE DJ DK DM DO DZ -EC EE EG EH ER ES ET -FI FJ FK FM FO FR -GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY -HK HM HN HR HT HU -ID IE IL IM IN IO IQ IR IS IT -JE JM JO JP -KE KG KH KI KM KN KP KR KW KY KZ -LA LB LC LI LK LR LS LT LU LV LY -MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ -NA NC NE NF NG NI NL NO NP NR NU NZ -OM -PA PE PF PG PH PK PL PM PN PR PS PT PW PY -QA -RE RO RS RU RW -SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ -TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ -UA UG UM US UY UZ -VA VC VE VG VI VN VU -WF WS -XK -YE YT -ZA ZM ZW -` - -// parseCountries reads a comma-separated list of country codes in either -// case, and returns them in capitals. -func parseCountries(value string) ([]string, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - countries := make([]string, 0, len(items)) - - for _, item := range items { - country, err := parseCountry(item) - if err != nil { - return nil, err - } - - countries = append(countries, country) - } - - return countries, nil -} - -// parseCountry reads a country code in either case, and returns it in -// capitals. -func parseCountry(value string) (string, error) { - country := strings.ToUpper(value) - if !slices.Contains(strings.Fields(countryCodes), country) { - return "", fmt.Errorf("%q %w", value, errNotCountry) - } - - return country, nil -} - -// ParseASN reads an AS number such as AS64496, in either case, and -// returns it as the lookup gives it: AS and the number, in capitals and -// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is -// read with it too. -func ParseASN(value string) (string, error) { - digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS") - - number, err := strconv.ParseUint(digits, 10, 32) - if !hasAS || err != nil { - return "", fmt.Errorf("%q %w", value, errNotASN) - } - - return "AS" + strconv.FormatUint(number, 10), nil -} - -// The tags a request can get that the settings can name, as waf -// writes them. The prefixes are followed by a value. -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 - // Rule Set matched. - ExploitSignatureTag = "exploitsignature" -) - -// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the -// requests of the clients it holds, and the URL it is fetched from. -type TagList struct { - Name string - URL string -} - -// parseTagLists reads a comma-separated list of name=URL pairs, such as -// torexit=https://example.com/tor.txt, and returns each list with its name -// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the -// = before http. A name or a URL listed twice is an error. -func parseTagLists(value string) ([]TagList, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - lists := make([]TagList, 0, len(items)) - - for _, item := range items { - end := strings.Index(item, "=http") - if end <= 0 || !isListURL(item[end+1:]) { - return nil, fmt.Errorf("%q %w", item, errNotTagList) - } - - list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]} - - if slices.ContainsFunc(lists, func(earlier TagList) bool { - return earlier.Name == list.Name || earlier.URL == list.URL - }) { - return nil, fmt.Errorf("%q %w", item, errListedTwice) - } - - lists = append(lists, list) - } - - return lists, nil -} - -// parseTags reads a comma-separated list of tags, which parseTag reads. A -// tag listed twice is an error, which shows the tag as MaskZoneKey does. -func parseTags(value string) ([]string, error) { - items, err := parseList(value) - if err != nil { - // parseList's error, for an empty item, shows the whole value, keys - // included. - return nil, errEmptyItem - } - - tags := make([]string, 0, len(items)) - - for _, item := range items { - tag, err := parseTag(item) - if err != nil { - return nil, err - } - - if slices.Contains(tags, tag) { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice) - } - - tags = append(tags, tag) - } - - return tags, nil -} - -// 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, 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)) - - if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN { - number, err := strconv.ParseUint(digits, 10, 32) - if err != nil { - return "", fmt.Errorf("%q %w", value, errNotASNTag) - } - - return ASNTagPrefix + strconv.FormatUint(number, 10), nil - } - - if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry { - country, err := parseCountry(code) - if err != nil { - return "", err - } - - 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 -} - -// parseTagPercents reads a comma-separated list of tags, each with : and a -// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed -// twice is an error, and so is ExploitSignatureTag, which a request gets -// only after the rate limits have counted it. -func parseTagPercents(value string) (map[string]int64, error) { - items, err := parseList(value) - if err != nil { - // parseList's error, for an empty item, shows the whole value, keys - // included. - return nil, errEmptyItem - } - - percents := make(map[string]int64, len(items)) - - for _, item := range items { - colon := strings.LastIndex(item, ":") - if colon <= 0 { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem) - } - - tag, err := parseTag(item[:colon]) - if err != nil { - return nil, err - } - - percent, err := strconv.ParseInt(item[colon+1:], 10, 64) - if err != nil || percent < 1 || percent > 99 { - return nil, fmt.Errorf("%q %w", MaskZoneKey(item[colon+1:]), errNotTagPercent) - } - - if tag == ExploitSignatureTag { - return nil, fmt.Errorf("%q %w", tag, errExploitTag) - } - - if _, listed := percents[tag]; listed { - // tag, since item[:colon] may end in a space, which MaskZoneKey - // would not mask. - return nil, fmt.Errorf("%q %w", MaskZoneKey(tag), errListedTwice) - } - - percents[tag] = percent - } - - return percents, nil -} - -// parsePercents reads a comma-separated list of items, each an AS number -// or a country, which parseCode reads, then : and a percentage, such as -// AS64496:50 or cn:25, and returns each one's percentage. An empty value -// is an empty list. An AS number or country listed twice is an error. -func parsePercents( - value string, parseCode func(string) (string, error), -) (map[string]int64, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - percents := make(map[string]int64, len(items)) - - for _, item := range items { - codeText, percentText, found := strings.Cut(item, ":") - if !found { - return nil, fmt.Errorf("%q %w", item, errNotPercentItem) - } - - code, err := parseCode(codeText) - if err != nil { - return nil, err - } - - percent, err := ParsePercent(percentText) - if err != nil { - return nil, err - } - - if _, listed := percents[code]; listed { - return nil, fmt.Errorf("%q %w", codeText, errListedTwice) - } - - percents[code] = percent - } - - return percents, nil -} - -// ParsePercent reads a percentage, a whole number from 0 to 100. The file -// SWW_ASN_LIMIT_PERCENT_URL names is read with it too. -func ParsePercent(value string) (int64, error) { - percent, err := strconv.ParseInt(value, 10, 64) - if err != nil || percent < 0 || percent > 100 { - return 0, fmt.Errorf("%q %w", value, errNotPercent) - } - - return percent, nil -} - -// headerNameChars are the characters RFC 9110 allows in a header name: -// letters, digits and these marks. -const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + - "0123456789!#$%&'*+-.^_`|~" - -// IsHeaderName reports whether name can be a header name: one or more of -// the characters RFC 9110 allows in one. -func IsHeaderName(name string) bool { - if name == "" { - return false - } - - for _, char := range name { - if !strings.ContainsRune(headerNameChars, char) { - return false - } - } - - return true -} - -// parseHeaderNames reads a comma-separated list of header names in either -// case, and returns them in lower case. Host and Transfer-Encoding are -// refused: Go's HTTP server takes them out of the request's headers. -func parseHeaderNames(value string) ([]string, error) { - items, err := parseList(value) - if err != nil { - return nil, err - } - - headers := make([]string, 0, len(items)) - - for _, item := range items { - if !IsHeaderName(item) { - return nil, fmt.Errorf("%q %w", item, errNotHeaderName) - } - - header := strings.ToLower(item) - switch header { - case "host": - return nil, fmt.Errorf( - "%q %w; the request's host is the field host", - item, - errHeaderTakenOut, - ) - case "transfer-encoding": - return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) - } - - headers = append(headers, header) - } - - return headers, nil -} - // parseListenAddr checks an address to listen on: an optional host and a // port number. func parseListenAddr(value string) (string, error) { @@ -2246,336 +259,3 @@ func parseListenAddr(value string) (string, error) { return value, nil } - -// parseUpstreamURL reads the app's URL: http or https, a host and an -// optional port from 1 to 65535, and nothing else, since the request's -// own path and query go to the app unchanged. -func parseUpstreamURL(value string) (*url.URL, error) { - upstream, err := url.Parse(value) - if err != nil { - return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) - } - - onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && - upstream.Hostname() != "" && upstream.User == nil && - upstream.Opaque == "" && - (upstream.Path == "" || upstream.Path == "/") && - upstream.RawQuery == "" && - upstream.Fragment == "" - if !onlySchemeAndHost { - return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) - } - - if upstream.Port() != "" { - port, err := strconv.ParseUint(upstream.Port(), 10, 16) - if err != nil || port == 0 { - return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) - } - } - - return upstream, nil -} - -// parseLogRemoteURL reads where every log line is also sent: -// syslog+udp, syslog+tcp or syslog+tls, a host and a port from 1 to -// 65535, and nothing else. -func parseLogRemoteURL(value string) (*url.URL, error) { - remote, err := url.Parse(value) - if err != nil { - return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) - } - - schemes := []string{ - remotelog.SchemeUDP, - remotelog.SchemeTCP, - remotelog.SchemeTLS, - } - port, err := strconv.ParseUint(remote.Port(), 10, 16) - - onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && - remote.Hostname() != "" && err == nil && port != 0 && - remote.User == nil && remote.Opaque == "" && - (remote.Path == "" || remote.Path == "/") && - remote.RawQuery == "" && remote.Fragment == "" - if !onlySchemeHostAndPort { - return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) - } - - return remote, nil -} - -// parseFacility reads the name of a syslog facility, and returns its -// number, as RFC 5424 numbers them. -func parseFacility(value string) (int, error) { - //nolint:mnd // the facilities' numbers in RFC 5424 - number, known := map[string]int{ - "kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5, - "lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11, - "local0": 16, "local1": 17, "local2": 18, "local3": 19, - "local4": 20, "local5": 21, "local6": 22, "local7": 23, - }[value] - if !known { - return 0, fmt.Errorf("%q %w", value, errNotFacility) - } - - return number, nil -} - -// parseWebhookURL reads where each alert is posted: http or https, a -// host, and an optional port from 1 to 65535, path and query, without a -// user or a fragment. It returns the URL, and how the log shows it: its -// scheme and host, and ******** in place of its path and query, if it has -// either. An error shows no part of the value. An empty value is no URL. -func parseWebhookURL(value string) (*url.URL, string, error) { - if value == "" { - return nil, "", nil - } - - webhook, err := url.Parse(value) - if err != nil || !isHTTPURL(webhook) { - return nil, "", errNotWebhookURL - } - - logged := webhook.Scheme + "://" + webhook.Host - if webhook.Path != "" || webhook.RawQuery != "" { - logged += "/" + masked - } - - return webhook, logged, nil -} - -// isHTTPURL reports whether u is http or https, with a host, and an -// optional port from 1 to 65535, path and query, without a user or a -// fragment. -func isHTTPURL(u *url.URL) bool { - port, err := strconv.ParseUint(u.Port(), 10, 16) - - return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && - (u.Port() == "" || (err == nil && port != 0)) && - u.User == nil && - u.Opaque == "" && - u.Fragment == "" -} - -// isListURL reports whether value is a URL a list can be fetched from, as -// isHTTPURL says. -func isListURL(value string) bool { - u, err := url.Parse(value) - - return err == nil && isHTTPURL(u) -} - -// parseListURLs reads a comma-separated list of the URLs lists are fetched -// from. A URL listed twice is an error: it would be fetched twice as -// often. -func parseListURLs(value string) ([]string, error) { - urls, err := parseList(value) - if err != nil { - return nil, err - } - - for i, listURL := range urls { - if !isListURL(listURL) { - return nil, fmt.Errorf("%q %w", listURL, errNotListURL) - } - - if slices.Contains(urls[:i], listURL) { - return nil, fmt.Errorf("%q %w", listURL, errListedTwice) - } - } - - return urls, nil -} - -const ( - // maxZoneLength is the most characters a DNSBL zone may have: 253, the - // most a DNS name may have, less the 64 that come before the zone in - // the name an IPv6 client is asked about by, its 32 hex digits each - // followed by a dot. - maxZoneLength = 189 - // maxLabelLength is the most characters a label of a DNS name may have. - maxLabelLength = 63 - // labelChars are the characters a label of a DNS zone may hold. - labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" - // dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER - // gives none. - dnsPort = 53 -) - -// parseZones reads a comma-separated list of DNSBL zones, each a DNS name -// such as dnsbl.dronebl.org: labels separated by dots, each of 1 to 63 -// letters, digits and hyphens, neither starting nor ending with a hyphen, -// and at most maxZoneLength characters in all. Go's resolver takes any -// other name for one that does not exist, so that the zone would list no -// client. A zone listed twice is an error, whatever the case of its -// letters, which DNS names ignore, and whatever its key, since -// MaskZoneKey shows two keys of one zone alike. An error shows a zone as -// MaskZoneKey does. -func parseZones(value string) ([]string, error) { - zones, err := parseList(value) - if err != nil { - // parseList's error, for an empty item, shows the whole value, keys - // included. - return nil, errEmptyItem - } - - for i, zone := range zones { - shown := MaskZoneKey(zone) - listedBefore := slices.ContainsFunc( - zones[:i], - func(earlier string) bool { - return strings.EqualFold(MaskZoneKey(earlier), shown) - }, - ) - - switch { - case len(zone) > maxZoneLength: - return nil, fmt.Errorf("%q %w", shown, errZoneTooLong) - case !isZone(zone): - return nil, fmt.Errorf("%q %w", shown, errNotZone) - case listedBefore: - return nil, fmt.Errorf("%q %w", shown, errListedTwice) - } - } - - return zones, nil -} - -// MaskZoneKey returns zone with ******** in place of its key, if it is a -// zone of Spamhaus's keyed query service, a name under dq.spamhaus.net, -// such as .xbl.dq.spamhaus.net, whose first label is the key. Any -// other zone it returns as it is. A zone is shown so wherever it leaves -// the process: in the log, the alerts and the metrics. -func MaskZoneKey(zone string) string { - // DNS names ignore case, and a name may be written with a dot at its - // end. - name := strings.TrimSuffix(strings.ToLower(zone), ".") - if !strings.HasSuffix(name, ".dq.spamhaus.net") { - return zone - } - - _, rest, _ := strings.Cut(zone, ".") - - return masked + "." + rest -} - -// isZone reports whether each label of zone is as parseZones takes it. -func isZone(zone string) bool { - for label := range strings.SplitSeq(zone, ".") { - badChar := strings.ContainsFunc(label, func(char rune) bool { - return !strings.ContainsRune(labelChars, char) - }) - - if label == "" || len(label) > maxLabelLength || badChar || - strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { - return false - } - } - - return true -} - -// parseResolver reads the resolver the DNSBL zones are asked through: an -// IP address with a port from 1 to 65535, such as 192.0.2.53:5353 or -// [2001:db8::53]:5353, or without one, such as 192.0.2.53 or 2001:db8::53, -// for port 53. An empty value is none, the zero AddrPort. -func parseResolver(value string) (netip.AddrPort, error) { - if value == "" { - return netip.AddrPort{}, nil - } - - resolver, err := netip.ParseAddrPort(value) - if err == nil && resolver.Port() != 0 { - return resolver, nil - } - - addr, err := netip.ParseAddr(value) - if err != nil { - return netip.AddrPort{}, fmt.Errorf("%q %w", value, errNotResolver) - } - - return netip.AddrPortFrom(addr, dnsPort), nil -} - -// parseWebhookHeaders reads a comma-separated list of headers, each its -// name, :, and its value, and returns them, and how the log shows them, -// with each value as ********. An error names the item by its place in -// the list, so that it shows no value. An empty value is an empty list. -func parseWebhookHeaders(value string) (http.Header, string, error) { - headers := http.Header{} - if strings.TrimSpace(value) == "" { - return headers, "", nil - } - - logged := []string{} - - for i, item := range strings.Split(value, ",") { - name, headerValue, found := strings.Cut(item, ":") - name = strings.TrimSpace(name) - - if !found || !IsHeaderName(name) || - strings.ContainsAny(headerValue, "\r\n\x00") { - return nil, "", fmt.Errorf( - "item %d %w", - i+1, - errNotWebhookHeader, - ) - } - - headers.Add(name, strings.TrimSpace(headerValue)) - logged = append(logged, name+":"+masked) - } - - return headers, strings.Join(logged, ","), nil -} - -// parseAlertEvents reads a comma-separated list of the events alerts can -// be sent for. -func parseAlertEvents(value string) ([]string, error) { - events, err := parseList(value) - if err != nil { - return nil, err - } - - for _, event := range events { - if !slices.Contains(alerts.Events(), event) { - return nil, fmt.Errorf("%q %w", event, errNotAlertEvent) - } - } - - return events, nil -} - -// parseNumberOrOff reads a whole number above zero, or off, which is 0. -func parseNumberOrOff(value string) (int, error) { - if value == off { - return 0, nil - } - - n, err := strconv.Atoi(value) - if err != nil || n <= 0 { - return 0, fmt.Errorf("%q %w", value, errNotNumberOrOff) - } - - return n, nil -} - -// appNameMaxLength is the most characters RFC 5424 allows in an -// APP-NAME. -const appNameMaxLength = 48 - -// isAppName reports whether value can be an APP-NAME: 1 to -// appNameMaxLength printable ASCII characters, none of them a space. -func isAppName(value string) bool { - if value == "" || len(value) > appNameMaxLength { - return false - } - - for _, char := range []byte(value) { - if char < '!' || char > '~' { - return false - } - } - - return true -} diff --git a/internal/config/rproxy.go b/internal/config/rproxy.go index 0dbf608..558b02b 100644 --- a/internal/config/rproxy.go +++ b/internal/config/rproxy.go @@ -48,7 +48,17 @@ type RProxy struct { settings []slog.Attr } +// defaultUpstreamURL is the default of SWRP_UPSTREAM_URL. +const defaultUpstreamURL = "http://127.0.0.1:8081" + +// privateRanges are the private address ranges, the default trusted +// proxies. +const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + var ( + errNotUpstreamURL = errors.New( + "is not a URL with only a scheme, a host and an optional port, " + + "such as http://127.0.0.1:8081") errNeedsWAFBackend = errors.New( "is required: it is waf's URL, such as http://waf:8080") errNotDisposition = errors.New("is not allow or deny") @@ -124,6 +134,14 @@ func (c *RProxy) LogValue() slog.Value { return slog.GroupValue(c.settings...) } +// appURL reads a setting that is the app's URL. +func (e *environment) appURL(name, defaultValue string) *url.URL { + upstream, err := parseUpstreamURL(e.value(name, defaultValue)) + e.check(name, err) + + return upstream +} + // wafBackend reads the setting that is waf's URL, which must be set. func (e *environment) wafBackend(name string) *url.URL { value := e.value(name, "") @@ -190,3 +208,32 @@ func parseMilliseconds(value string) (time.Duration, bool) { return time.Duration(n) * time.Millisecond, true } + +// parseUpstreamURL reads the app's URL: http or https, a host and an +// optional port from 1 to 65535, and nothing else, since the request's +// own path and query go to the app unchanged. +func parseUpstreamURL(value string) (*url.URL, error) { + upstream, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) + } + + onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && + upstream.Hostname() != "" && upstream.User == nil && + upstream.Opaque == "" && + (upstream.Path == "" || upstream.Path == "/") && + upstream.RawQuery == "" && + upstream.Fragment == "" + if !onlySchemeAndHost { + return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) + } + + if upstream.Port() != "" { + port, err := strconv.ParseUint(upstream.Port(), 10, 16) + if err != nil || port == 0 { + return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) + } + } + + return upstream, nil +} diff --git a/internal/config/waf.go b/internal/config/waf.go new file mode 100644 index 0000000..d5cd7ec --- /dev/null +++ b/internal/config/waf.go @@ -0,0 +1,2293 @@ +package config + +import ( + "crypto/x509" + "errors" + "fmt" + "log/slog" + "maps" + "math" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "sneak.berlin/go/smallwebwaf/internal/alerts" + "sneak.berlin/go/smallwebwaf/internal/anomaly" + "sneak.berlin/go/smallwebwaf/internal/remotelog" +) + +// Config is the settings of `sww waf`. A time or size limit of +// zero is off. +type Config struct { + // ListenAddr is where waf listens (SWW_LISTEN_ADDR). + ListenAddr string + // InstanceName is the name every log line and alert gives as instance, + // and every metric carries as its label instance (SWW_INSTANCE_NAME), + // by default the host's name, which docker sets to the first 12 + // characters of the container's id. + InstanceName string + // Observe is true in observe mode, when SWW_MODE is observe rather + // than enforce: a request that SWW_DENY_NETS, a ban, the country + // lists, a rate limit, a rule or the Core Rule Set would refuse is + // allowed instead, and no ban is made. + Observe bool + // IPv6GroupPrefix is the length of the IPv6 netblock that is one client + // (SWW_IPV6_GROUP_PREFIX), from 32 to 128. + IPv6GroupPrefix int + // MaxTrackedClients is the most clients the table of clients holds, in + // memory and in clients.json (SWW_MAX_TRACKED_CLIENTS). + MaxTrackedClients int + // AllowNets are the netblocks whose clients skip every check + // (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the + // rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS). + // DenyNets are those whose clients are always refused + // (SWW_DENY_NETS). + AllowNets []netip.Prefix + RateLimitExemptNets []netip.Prefix + DenyNets []netip.Prefix + // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the + // most requests a client may make in a minute, an hour and a day + // (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and + // SWW_RATE_LIMIT_PER_DAY). + RateLimitPerMinute int64 + RateLimitPerHour int64 + RateLimitPerDay int64 + // RateLimitExemptPaths are the path prefixes whose requests the rate + // limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS). + // Each starts with /. + RateLimitExemptPaths []string + // BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the + // most bytes a client's requests may carry in a minute, an hour and a + // day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and + // SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count + // toward them (SWW_BYTES_COUNT): response, request or both. + BytesLimitPerMinute int64 + BytesLimitPerHour int64 + BytesLimitPerDay int64 + BytesCount string + // LookupSource is where each client's AS number and country are + // looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere. + // LookupDBPath is the lookup database, the IPinfo Lite file looked up + // in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any + // other source. A request waits up to LookupTimeout for its client's + // first answer from GeoJS while a setting needs it + // (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true + // when the app is passed the client's AS number and country in headers + // (SWW_ADD_LOOKUP_HEADERS). + LookupSource string + LookupDBPath string + LookupTimeout time.Duration + AddLookupHeaders bool + // ExclusivelyAllowedCountries, when not empty, are the only countries + // whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES), + // two-letter codes in capitals, as GeoJS gives them. + ExclusivelyAllowedCountries []string + // TagLists are the address lists that each add a tag to the requests of + // the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose + // requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients + // with a tag that percentage of every rate limit and byte limit, from 1 + // to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in + // lower case, a country code in capitals. + 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 + // (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds + // percentages from 0 to 100, by AS number, written as AS64496, or by + // country, a two-letter code in capitals, as the lookup gives them. + // UnknownLimitPercent is the percentage of every limit a client without + // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is + // where a file of AS:percent lines is fetched from, whose percentages + // count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL), + // "" while it is unset. + ASNBytesPercent map[string]int64 + CountryBytesPercent map[string]int64 + UnknownLimitPercent int64 + ASNLimitPercentURL string + // BlocklistURLs are where the blocklists are fetched from + // (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is + // fetched again BlocklistRefresh after it was last fetched or tried + // (SWW_BLOCKLIST_REFRESH), which is never less than an hour. + // BlocklistAction is what is done with a client a blocklist lists + // (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit, + // BlocklistLimitPercent is the percentage of every limit it gets. + BlocklistURLs []string + BlocklistRefresh time.Duration + BlocklistAction string + BlocklistLimitPercent int64 + // DNSBLZones are the DNSBL zones clients are asked about + // (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or + // the host's resolver while that is the zero AddrPort. + // AbuseIPDBKey is the key of the AbuseIPDB account clients are checked + // with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score + // of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and + // at most AbuseIPDBDailyBudget checks are made a day + // (SWW_ABUSEIPDB_DAILY_BUDGET). + // ReputationAction is what is done with a client a zone's verdict lists, + // or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log; + // for limit, ReputationLimitPercent is the percentage of every limit it + // gets. A verdict or a score is used for ReputationCacheTTL after it was + // fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take + // ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off. + DNSBLZones []string + DNSBLResolver netip.AddrPort + AbuseIPDBKey string + AbuseIPDBMinScore int64 + AbuseIPDBDailyBudget int + ReputationAction string + ReputationLimitPercent int64 + ReputationCacheTTL time.Duration + ReputationTimeout time.Duration + // CrowdSecDecisionsURL is where the decision list of the CrowdSec + // engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from: + // that URL with v1/decisions added to its path, "" while it is unset and + // none is. CrowdSecKey is the key the engine is asked with + // (SWW_CROWDSEC_LAPI_KEY). + CrowdSecDecisionsURL string + CrowdSecKey string + // BanResponse is the status a refused client is answered with, 403 + // or 429, or 0 to close the connection without an answer + // (SWW_BAN_RESPONSE). It answers a banned client, a request that + // breaks a rate limit or matches a ban rule, SWW_DENY_NETS, + // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY. + BanResponse int + // LimitBanDuration is the ban for a first broken rate limit + // (SWW_LIMIT_BAN_DURATION). A limit broken again within + // LimitBanRepeatWindow after the last ban ended bans for three times + // as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that + // would be longer than MaxBanDuration is permanent instead + // (SWW_MAX_BAN_DURATION). None of them can be off. + LimitBanDuration time.Duration + LimitBanRepeatWindow time.Duration + MaxBanDuration time.Duration + // AttackBanDuration is the ban for a first clear sign of attack + // (SWW_ATTACK_BAN_DURATION). It cannot be off. + AttackBanDuration time.Duration + // MaxBans is the most bans held (SWW_MAX_BANS). + MaxBans int + // BanScopeV4Prefix is the length of the netblock around an IPv4 + // client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX). + 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 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 + // LogLevel is the least severe of the process's own messages that are + // written (SWW_LOG_LEVEL). It holds back no request log line. + LogLevel slog.Level + // AdminToken is the bearer token an admin sends for the ban endpoints + // and /_sww/clients/ (SWW_ADMIN_TOKEN), "" while it is + // unset and they are off. + AdminToken string + // MetricsToken is the bearer token a scraper sends for the metrics + // (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off. + // MetricsTopN is how many AS numbers and how many countries get series + // of their own in the metrics (SWW_METRICS_TOP_N). + MetricsToken string + MetricsTopN int + // RulesDir is the directory of the rule files (SWW_RULES_DIR), read + // unless RulesEnabled is false (SWW_RULES_ENABLED). + RulesDir string + RulesEnabled bool + // WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff, + // WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia + // level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and + // WAFAnomalyThreshold the anomaly score at which a request is a match + // (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules + // are the ids of its rules switched off (SWW_WAF_DISABLED_RULES), + // WAFExemptPaths the path prefixes it does not inspect + // (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body + // it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. + // CoreRuleSetExclusionsFile is the path of the file of its exclusions + // (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none. + WAFMode string + WAFParanoiaLevel int + WAFAnomalyThreshold int + WAFDisabledRules []int + WAFExemptPaths []string + WAFBodyLimit int64 + CoreRuleSetExclusionsFile string + // TrapPaths are the paths a request for which is a clear sign of + // attack (SWW_TRAP_PATHS), each starting with / and without a ?. + TrapPaths []string + // ErrorBurstThreshold is the most requests of a client within a minute + // that waf may refuse after a rule file or Core Rule Set match; one + // more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off. + ErrorBurstThreshold int64 + // LogRemoteURL is where every line on stdout is also sent + // (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent. + // LogRemoteTLSCAs are the certificates a syslog+tls endpoint's + // certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while + // it is unset and the host's own are used. LogRemoteBuffer is the most + // lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER). + // LogRemoteFacility is the number of the syslog facility + // (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME + // (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records + // the lines are sent in. + LogRemoteURL *url.URL + LogRemoteTLSCAs *x509.CertPool + LogRemoteBuffer int + LogRemoteFacility int + LogRemoteAppName string + // AlertWebhookURL is where each alert is posted as JSON + // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders + // are sent with each (SWW_ALERT_WEBHOOK_HEADERS). + // AlertSlackWebhookURL is the Slack incoming webhook each alert is + // posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and + // AlertNtfyURL the ntfy topic each is published to + // (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, + // unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN). + // With none of the three URLs set, no alert is sent. AlertEvents are + // the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an + // alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and + // so is an alert past AlertMaxPerHour in an hour, for the hour's + // summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both. + AlertWebhookURL *url.URL + AlertWebhookHeaders http.Header + AlertSlackWebhookURL *url.URL + AlertNtfyURL *url.URL + AlertNtfyToken string + AlertEvents []string + AlertCooldown time.Duration + AlertMaxPerHour int + // The anomaly thresholds, which only raise alerts: the most requests + // and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*), + // per netblock around a client (SWW_ANOMALY_NET_*), per AS number + // (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*) + // and per named netblock (SWW_WATCH_*), each 0 while it is off. + // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the + // netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and + // SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks + // (SWW_WATCH_NETS). + AnomalyClient anomaly.Thresholds + AnomalyNet anomaly.Thresholds + AnomalyASN anomaly.Thresholds + AnomalyTotal anomaly.Thresholds + AnomalyWatch anomaly.Thresholds + AnomalyNetV4Prefix int + AnomalyNetV6Prefix int + WatchNets []anomaly.NamedNetblock + + // settings are the values read, as given or by default, and the + // files they were read from, for the log line at start. + settings []slog.Attr +} + +// The values of SWW_WAF_MODE. +const ( + // WAFModeOff runs no request through the Core Rule Set. + WAFModeOff = off + // WAFModeDetect logs and alerts a match, and refuses nothing. + WAFModeDetect = "detect" + // WAFModeBlock refuses a match with 403. + WAFModeBlock = "block" +) + +// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the +// lookup database, the file SWW_LOOKUP_DB_PATH names. +const fileSource = "file" + +const ( + day = 24 * time.Hour + ipv4Bits = 32 + ipv6Bits = 128 + // minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the + // netblock a provider is usually given: a shorter one would make one + // client of the customers of several providers. + minIPv6GroupPrefix = 32 + // minTokenLength is the fewest characters a token may have. + minTokenLength = 32 + // maxParanoiaLevel is the Core Rule Set's highest paranoia level. + maxParanoiaLevel = 4 + // firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set + // keeps for the rules that set it up, which sww's own rules + // have too (see internal/coreruleset). Switching one off would undo a change + // that no setting undoes. + firstSetupRuleID = 900000 + lastSetupRuleID = 900999 + // masked is what the log shows for a token that is set, and in place of + // a secret in another setting. + masked = "********" +) + +var ( + errNotDuration = errors.New( + "is not a duration such as 90s, 15m or 7d, or off") + errNotCount = errors.New( + "is not a whole number of requests such as 1000, or off") + errNotCountry = errors.New( + "is not a two-letter country code such as de or kp") + errNotHeaderName = errors.New( + "is not a header name such as accept-language") + errHeaderTakenOut = errors.New( + "is taken out of every request by Go's HTTP server, so it can never " + + "be logged") + errOnBothLists = errors.New("is in SWW_TAG_DENY too") + errNotLookupSource = errors.New("is not geojs, file or off") + errNeedsLookups = errors.New("it needs each client looked up") + errNeedsDBPath = errors.New("it names the file to look clients up in") + errDBPathUnused = errors.New("only file reads it") + errOver64K = errors.New( + "is more than 64K, the most waf gets of a body") + errNotDurationAboveZero = errors.New( + "is not a duration above zero, such as 1h or 7d") + errNotNumberAboveZero = errors.New( + "is not a whole number above zero, such as 5000") + errNotBanResponse = errors.New("is not 403, 429 or close") + errNotV4Prefix = errors.New( + "is not the length of an IPv4 netblock, from 0 to 32, such as 24") + errNotV6Prefix = errors.New( + "is not the length of an IPv6 netblock, from 0 to 128, such as 48") + errNotIPv6GroupPrefix = errors.New( + "is not the length of an IPv6 netblock, from 32 to 128, such as 64") + errNotLogLevel = errors.New("is not debug, info, warn or error") + errNotNamedNetblock = errors.New( + "is not a name, = and a netblock, such as office=203.0.113.0/24") + errNotAbsolutePath = errors.New( + "is not an absolute path, such as /var/lib/sww") + errShortToken = errors.New("is shorter than 32 characters") + errNotMode = errors.New("is not enforce or observe") + errNotBytesCount = errors.New("is not response, request or both") + errNotPathPrefix = errors.New( + "is not a path prefix starting with /, such as /assets/") + errNotTrapPath = errors.New( + "is not a path starting with / and without a ?, such as /wp-login.php", + ) + errNotWAFMode = errors.New("is not off, detect or block") + errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") + errNotRuleID = errors.New( + "is not the id of a Core Rule Set rule, a whole number such as 942100", + ) + errSetupRuleID = errors.New( + "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + + "up and of sww's own, which cannot be switched off") + errNotBoolean = errors.New("is not true or false") + errNotLogRemoteURL = errors.New( + "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + + "and nothing more, such as syslog+tls://logs.example:6514", + ) + errNoCertificate = errors.New("holds no PEM certificate") + errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") + errNotAppName = errors.New( + "is not 1 to 48 printable ASCII characters without a space, such as blog") + errNotWebhookURL = errors.New( + "is not an http or https URL without a user or a fragment, " + + "such as https://alerts.example/sww") + errNotWebhookHeader = errors.New( + "is not a header name followed by : and the header's value, " + + "such as Authorization:Bearer ") + errControlCharacter = errors.New( + "holds a control character, such as the carriage return of a Windows line end", + ) + errNotAlertEvent = errors.New( + "is not ban, permanent_ban, core_rule_set_match, anomaly, reputation_hit, " + + "source_failure or file_error") + errNotNumberOrOff = errors.New( + "is not a whole number above zero, such as 60, or off", + ) + errNotUTF8 = errors.New("is not valid UTF-8") + errNotASN = errors.New("is not an AS number such as AS64496") + errNotPercentItem = errors.New( + "is not a code, : and a percentage, such as AS64496:50 or cn:25") + errNotPercent = errors.New( + "is not a percentage, a whole number from 0 to 100", + ) + errListedTwice = errors.New("is listed twice") + errNotListURL = errors.New( + "is not an http or https URL without a user or a fragment, " + + "such as https://www.spamhaus.org/drop/drop.txt") + errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too") + errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") + errNotAction = errors.New( + "is not deny, limit: such as limit:25, or log") + errNotZone = errors.New( + "is not a DNS zone such as dnsbl.dronebl.org", + ) + errZoneTooLong = errors.New( + "is longer than 189 characters, too long for the " + + "names IPv6 clients are asked about by", + ) + errNotResolver = errors.New( + "is not an IP address with an optional port, " + + "such as 192.0.2.53 or [2001:db8::53]:5353", + ) + errNotLAPIURL = errors.New( + "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( + "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too") + errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " + + "SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too") + errNotTagList = errors.New( + "is not a name, = and an http or https URL, such as " + + "torexit=https://example.com/tor.txt") + errNotTagPercentItem = errors.New( + "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") + 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( + "cannot be used: a CrowdSec decision refuses the request first") + errNoRequestGetsTag = errors.New( + "is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " + + "asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " + + "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 +// os.LookupEnv. A setting may instead be given as a file: the variable +// named by the setting's name with _FILE added names the file, which is +// read now (see lookup). A setting that is not set takes its default. A +// setting that is set but invalid is an error that names it. +// +//nolint:funlen // one line for each setting, a list that grows with them +func FromEnvironment( + lookupEnv func(string) (string, bool), +) (*Config, error) { + env := &environment{lookupEnv: lookupEnv} + cfg := &Config{ + ListenAddr: env.address("SWW_LISTEN_ADDR"), + InstanceName: env.instanceName(), + Observe: env.observe("SWW_MODE", "enforce"), + IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"), + MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"), + AllowNets: env.netblocks("SWW_ALLOW_NETS", ""), + RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""), + DenyNets: env.netblocks("SWW_DENY_NETS", ""), + RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"), + RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"), + RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"), + RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""), + BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"), + BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"), + BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"), + BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"), + LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"), + LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""), + LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"), + AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"), + ExclusivelyAllowedCountries: env.countries( + "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), + 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"), + ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"), + BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"), + BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"), + DNSBLZones: env.zones("SWW_DNSBL_ZONES"), + DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"), + AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"), + AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"), + AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"), + ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"), + ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"), + CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"), + CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"), + BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"), + LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"), + LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"), + MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"), + AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"), + MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"), + BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"), + 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"), + AdminToken: env.token("SWW_ADMIN_TOKEN"), + MetricsToken: env.token("SWW_METRICS_TOKEN"), + MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"), + RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"), + RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"), + WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock), + WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"), + WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"), + WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES", + "920340,920420,920440,920640,930130,930140"), + WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""), + WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), + CoreRuleSetExclusionsFile: env.filePath( + "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), + TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), + ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), + LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), + LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"), + LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"), + LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"), + AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"), + AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"), + AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"), + AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"), + AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"), + AlertEvents: env.alertEvents("SWW_ALERT_EVENTS", + strings.Join(alerts.Events(), ",")), + AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"), + AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"), + AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"), + AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"), + AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"), + AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"), + AnomalyWatch: env.thresholds("SWW_WATCH_"), + AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"), + AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"), + WatchNets: env.namedNetblocks("SWW_WATCH_NETS"), + } + + cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME", + cfg.InstanceName, cfg.LogRemoteURL != nil) + cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action( + "SWW_BLOCKLIST_ACTION", "deny") + cfg.ReputationAction, cfg.ReputationLimitPercent = env.action( + "SWW_REPUTATION_ACTION", "limit:25") + + env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) + env.checkLookupDBPath(cfg) + env.checkCountriesAndLookups(cfg) + env.checkASNLimitPercentURL(cfg) + env.checkCrowdSec(cfg) + env.checkTagLists(cfg) + env.checkTags(cfg) + + if env.err != nil { + return nil, env.err + } + + cfg.settings = env.settings + + return cfg, nil +} + +// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a +// file, as FromEnvironment does. The health check in waf's container needs +// no other setting, so it reads no other, nor a file that another names. +func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) { + env := &environment{lookupEnv: lookupEnv} + listenAddr := env.address("SWW_LISTEN_ADDR") + + return listenAddr, env.err +} + +// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a +// file, as FromEnvironment does, so that the line saying a setting is +// invalid carries it too. A file that cannot be read gives the default +// here, and FromEnvironment then stops the start over it. +func InstanceName(lookupEnv func(string) (string, bool)) string { + env := &environment{lookupEnv: lookupEnv} + + return env.instanceName() +} + +// LogValue makes a Config log as each setting's name with the value it was +// given, or its default. +func (c *Config) LogValue() slog.Value { + return slog.GroupValue(c.settings...) +} + +// observe reads the setting that is the mode, enforce or observe, and +// reports whether it is observe. +func (e *environment) observe(name, defaultValue string) bool { + mode := e.value(name, defaultValue) + if mode != "enforce" && mode != "observe" { + e.check(name, fmt.Errorf("%q %w", mode, errNotMode)) + } + + return mode == "observe" +} + +// boolean reads a setting that is true or false. +func (e *environment) boolean(name, defaultValue string) bool { + value := e.value(name, defaultValue) + if value != "true" && value != "false" { + e.check(name, fmt.Errorf("%q %w", value, errNotBoolean)) + } + + return value == "true" +} + +// duration reads a setting that is a duration. +func (e *environment) duration(name, defaultValue string) time.Duration { + duration, err := parseDuration(e.value(name, defaultValue)) + e.check(name, err) + + return duration +} + +// wafBodyLimit reads the setting that is the most of a request body the +// Core Rule Set reads. +func (e *environment) wafBodyLimit(name, defaultValue string) int64 { + limit, err := parseWAFBodyLimit(e.value(name, defaultValue)) + e.check(name, err) + + return limit +} + +// count reads a setting that is a number of requests. +func (e *environment) count(name, defaultValue string) int64 { + count, err := parseCount(e.value(name, defaultValue)) + e.check(name, err) + + return count +} + +// bytesCount reads the setting that is which body bytes count toward the +// byte limits: response, request or both. +func (e *environment) bytesCount(name, defaultValue string) string { + value := e.value(name, defaultValue) + if value != "response" && value != "request" && value != "both" { + e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount)) + } + + return value +} + +// pathPrefixes reads a setting that is a list of path prefixes. +func (e *environment) pathPrefixes(name, defaultValue string) []string { + prefixes, err := parsePathPrefixes(e.value(name, defaultValue)) + e.check(name, err) + + return prefixes +} + +// trapPaths reads the setting that is the list of trap paths. It is empty +// by default. +func (e *environment) trapPaths(name string) []string { + paths, err := parseTrapPaths(e.value(name, "")) + e.check(name, err) + + return paths +} + +// wafMode reads the setting that is what the Core Rule Set does: off, +// detect or block. +func (e *environment) wafMode(name, defaultValue string) string { + mode := e.value(name, defaultValue) + if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock { + e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode)) + } + + return mode +} + +// paranoiaLevel reads the setting that is the Core Rule Set's paranoia +// level, from 1 to 4. +func (e *environment) paranoiaLevel(name, defaultValue string) int { + value := e.value(name, defaultValue) + + level, err := strconv.Atoi(value) + if err != nil || level < 1 || level > maxParanoiaLevel { + e.check(name, fmt.Errorf("%q %w", value, errNotParanoiaLevel)) + } + + return level +} + +// ruleIDs reads the setting that is a list of the ids of Core Rule Set +// rules. +func (e *environment) ruleIDs(name, defaultValue string) []int { + ids, err := parseRuleIDs(e.value(name, defaultValue)) + e.check(name, err) + + return ids +} + +// countries reads a setting that is a list of countries. +func (e *environment) countries(name, defaultValue string) []string { + countries, err := parseCountries(e.value(name, defaultValue)) + e.check(name, err) + + return countries +} + +// percents reads a setting that is a list of AS numbers or countries, +// which parseCode reads, each with a percentage. It is empty by default. +func (e *environment) percents( + name string, parseCode func(string) (string, error), +) map[string]int64 { + percents, err := parsePercents(e.value(name, ""), parseCode) + e.check(name, err) + + return percents +} + +// tagLists reads SWW_TAG_LISTS, which is empty by default. +func (e *environment) tagLists(name string) []TagList { + lists, err := parseTagLists(e.value(name, "")) + e.check(name, err) + + return lists +} + +// tags reads a setting that is a list of tags. It is empty by default. +func (e *environment) tags(name string) []string { + tags, err := parseTags(e.value(name, "")) + e.check(name, err) + + return tags +} + +// tagPercents reads a setting that is a list of tags, each with a +// percentage. It is empty by default. +func (e *environment) tagPercents(name string) map[string]int64 { + percents, err := parseTagPercents(e.value(name, "")) + e.check(name, err) + + return percents +} + +// percent reads a setting that is a percentage, from 0 to 100. +func (e *environment) percent(name, defaultValue string) int64 { + percent, err := ParsePercent(e.value(name, defaultValue)) + e.check(name, err) + + return percent +} + +// listURL reads a setting that is the URL a list is fetched from, "" while +// it is unset or empty. +func (e *environment) listURL(name string) string { + value := e.value(name, "") + if value != "" && !isListURL(value) { + e.check(name, fmt.Errorf("%q %w", value, errNotListURL)) + } + + return value +} + +// listURLs reads a setting that is a list of the URLs lists are fetched +// from. It is empty by default. +func (e *environment) listURLs(name string) []string { + urls, err := parseListURLs(e.value(name, "")) + e.check(name, err) + + return urls +} + +// refresh reads the setting that is how long after a list was last +// fetched or tried it is fetched again: a duration of an hour or more, +// since the Spamhaus lists may be fetched no more often, which cannot be +// off. +func (e *environment) refresh(name, defaultValue string) time.Duration { + value := e.value(name, defaultValue) + + duration, err := parseDuration(value) + if err != nil || duration < time.Hour { + e.check(name, fmt.Errorf("%q %w", value, errNotAnHourOrMore)) + } + + return duration +} + +// action reads a setting that is what is done with a client a blocklist +// or a DNSBL zone lists: deny, log, or limit:, which it returns +// as limit and the percentage. +func (e *environment) action(name, defaultValue string) (string, int64) { + value := e.value(name, defaultValue) + if value == "deny" || value == "log" { + return value, 0 + } + + percentText, isLimit := strings.CutPrefix(value, "limit:") + + percent, err := ParsePercent(percentText) + if !isLimit || err != nil { + e.check(name, fmt.Errorf("%q %w", value, errNotAction)) + } + + return "limit", percent +} + +// zones reads the setting that is the list of DNSBL zones. It is empty by +// default. The log shows each zone with its key masked, as MaskZoneKey +// masks it. +func (e *environment) zones(name string) []string { + value, _ := e.lookup(name) + zones, err := parseZones(value) + e.check(name, err) + + logged := make([]string, len(zones)) + for i, zone := range zones { + logged[i] = MaskZoneKey(zone) + } + + e.settings = append( + e.settings, + slog.String(name, strings.Join(logged, ",")), + ) + + return zones +} + +// resolver reads the setting that is the resolver the DNSBL zones are +// asked through, the zero AddrPort while it is unset or empty. +func (e *environment) resolver(name string) netip.AddrPort { + resolver, err := parseResolver(e.value(name, "")) + e.check(name, err) + + return resolver +} + +// crowdSecDecisionsURL reads the setting that is the URL of the CrowdSec +// engine's local API, such as http://172.17.0.1:8080, and returns the URL +// its decision list is fetched from, that URL with v1/decisions added to +// its path, "" while it is unset or empty. +func (e *environment) crowdSecDecisionsURL(name string) string { + value := e.value(name, "") + if value == "" { + return "" + } + + lapi, err := url.Parse(value) + if err != nil || !isHTTPURL(lapi) { + e.check(name, fmt.Errorf("%q %w", value, errNotLAPIURL)) + + return "" + } + + 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 { + source := e.value(name, defaultValue) + if source != "geojs" && source != fileSource && source != off { + e.check(name, fmt.Errorf("%q %w", source, errNotLookupSource)) + } + + return source +} + +// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without +// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source: +// one source at a time. +func (e *environment) checkLookupDBPath(cfg *Config) { + switch { + case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": + e.check("SWW_LOOKUP_SOURCE", fmt.Errorf( + "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) + case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": + e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf( + "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) + } +} + +// checkCountriesAndLookups refuses a country in +// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and, +// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked +// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name +// an asn= or country= tag, the biased thresholds, +// SWW_ASN_LIMIT_PERCENT_URL among them, of which +// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a +// limit, and the anomaly thresholds per AS number. +func (e *environment) checkCountriesAndLookups(cfg *Config) { + for _, country := range cfg.ExclusivelyAllowedCountries { + if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) { + e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", + fmt.Errorf("%q %w", country, errOnBothLists)) + } + } + + if cfg.LookupSource != off { + 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, 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}, + {"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, + {"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, + {"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, + {"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, + {"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, + } { + if setting.set { + e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w", + errNeedsLookups)) + } + } +} + +// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a +// blocklist too: the file at a URL is fetched as one list or the other. +func (e *environment) checkASNLimitPercentURL(cfg *Config) { + if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) { + e.check("SWW_ASN_LIMIT_PERCENT_URL", + fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs)) + } +} + +// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without +// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list +// that is fetched as another list too. +func (e *environment) checkCrowdSec(cfg *Config) { + decisionsURL := cfg.CrowdSecDecisionsURL + + switch { + case decisionsURL != "" && cfg.CrowdSecKey == "": + e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf( + "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) + case decisionsURL == "" && cfg.CrowdSecKey != "": + e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf( + "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) + case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || + decisionsURL == cfg.ASNLimitPercentURL): + e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", + decisionsURL, errAnotherList)) + } +} + +// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as +// another list too: the file at a URL is read as one kind of list. +func (e *environment) checkTagLists(cfg *Config) { + for _, list := range cfg.TagLists { + if slices.Contains(cfg.BlocklistURLs, list.URL) || + list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL { + e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise)) + } + } +} + +// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would +// never apply: crowdsec, whose request is refused before tags are acted on, and +// any tag these settings cannot give a request. +func (e *environment) checkTags(cfg *Config) { + for _, tag := range cfg.TagDeny { + e.check("SWW_TAG_DENY", checkTag(cfg, tag)) + } + + for tag := range cfg.TagLimitPercent { + e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag)) + } +} + +// checkTag returns an error when tag would never apply, as checkTags says. +func checkTag(cfg *Config, tag string) error { + if tag == CrowdSecTag { + return fmt.Errorf("%q %w", tag, errCrowdSecTag) + } + + if !requestCanGetTag(cfg, tag) { + return fmt.Errorf("%q %w", MaskZoneKey(tag), errNoRequestGetsTag) + } + + return nil +} + +// requestCanGetTag reports whether anything cfg sets up can give a request tag. +func requestCanGetTag(cfg *Config, tag string) bool { + isListName := func(list TagList) bool { return list.Name == tag } + if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) { + return true + } + + if tag == ExploitSignatureTag { + coreRuleSetOn := cfg.WAFMode != WAFModeOff && cfg.WAFAnomalyThreshold != 0 + + return cfg.RulesEnabled || coreRuleSetOn + } + + if tag == AbuseIPDBTag { + 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 { + return strings.ToLower(MaskZoneKey(each)) == zone + }) +} + +// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT +// names an asn= or country= tag. +func (c *Config) TagsNeedLookup() bool { + return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c) +} + +// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn= +// or country= tag. +func tagLimitNeedsLookups(cfg *Config) bool { + for tag := range cfg.TagLimitPercent { + if isLookupTag(tag) { + return true + } + } + + return false +} + +// isLookupTag reports whether tag is an asn= or country= tag. +func isLookupTag(tag string) bool { + return strings.HasPrefix(tag, ASNTagPrefix) || strings.HasPrefix(tag, CountryTagPrefix) +} + +// headerNames reads a setting that is a list of header names, and +// returns them in lower case. +func (e *environment) headerNames(name, defaultValue string) []string { + headers, err := parseHeaderNames(e.value(name, defaultValue)) + e.check(name, err) + + return headers +} + +// durationNotOff reads a setting that is a duration and, unlike a +// timeout, cannot be off. +func (e *environment) durationNotOff( + name, defaultValue string, +) time.Duration { + duration, err := ParseDurationNotOff(e.value(name, defaultValue)) + e.check(name, err) + + return duration +} + +// numberNotOff reads a setting that is a whole number above zero, which +// cannot be off. +func (e *environment) numberNotOff(name, defaultValue string) int { + number, err := parseNumberNotOff(e.value(name, defaultValue)) + e.check(name, err) + + return number +} + +// banResponse reads a setting that is how a refused client is answered. +func (e *environment) banResponse(name, defaultValue string) int { + status, err := parseBanResponse(e.value(name, defaultValue)) + e.check(name, err) + + return status +} + +// v4Prefix reads a setting that is the length of an IPv4 netblock. +func (e *environment) v4Prefix(name, defaultValue string) int { + length, err := parseV4Prefix(e.value(name, defaultValue)) + e.check(name, err) + + return length +} + +// v6Prefix reads a setting that is the length of an IPv6 netblock. +func (e *environment) v6Prefix(name, defaultValue string) int { + length, err := parseV6Prefix(e.value(name, defaultValue)) + e.check(name, err) + + return length +} + +// ipv6GroupPrefix reads the setting that is the length of the IPv6 +// netblock that is one client, from minIPv6GroupPrefix to 128. +func (e *environment) ipv6GroupPrefix(name, defaultValue string) int { + value := e.value(name, defaultValue) + + length, err := strconv.Atoi(value) + if err != nil || length < minIPv6GroupPrefix || length > ipv6Bits { + e.check(name, fmt.Errorf("%q %w", value, errNotIPv6GroupPrefix)) + } + + return length +} + +// logLevel reads the setting that is the least severe of the process's +// own messages that are written: debug, info, warn or error. +func (e *environment) logLevel(name, defaultValue string) slog.Level { + value := e.value(name, defaultValue) + + level, known := map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warn": slog.LevelWarn, + "error": slog.LevelError, + }[value] + if !known { + e.check(name, fmt.Errorf("%q %w", value, errNotLogLevel)) + } + + return level +} + +// thresholds reads the four anomaly thresholds whose settings' names +// start with prefix: requests and bytes per minute and per hour. Each is +// off by default. +func (e *environment) thresholds(prefix string) anomaly.Thresholds { + return anomaly.Thresholds{ + RequestsPerMinute: e.count(prefix+"REQUESTS_PER_MINUTE", off), + RequestsPerHour: e.count(prefix+"REQUESTS_PER_HOUR", off), + BytesPerMinute: e.size(prefix+"BYTES_PER_MINUTE", off), + BytesPerHour: e.size(prefix+"BYTES_PER_HOUR", off), + } +} + +// namedNetblocks reads a setting that is a list of named netblocks. It is +// empty by default. +func (e *environment) namedNetblocks(name string) []anomaly.NamedNetblock { + named, err := parseNamedNetblocks(e.value(name, "")) + e.check(name, err) + + return named +} + +// absolutePath reads a setting that is an absolute path. +func (e *environment) absolutePath(name, defaultValue string) string { + path := e.value(name, defaultValue) + if !filepath.IsAbs(path) { + e.check(name, fmt.Errorf("%q %w", path, errNotAbsolutePath)) + } + + return path +} + +// token reads a setting that is a bearer token. Unset, it is "", which +// switches off what it guards; set, it must be at least minTokenLength +// characters. Neither the log nor an error shows its value. +func (e *environment) token(name string) string { + value, set := e.lookup(name) + if !set { + e.settings = append(e.settings, slog.String(name, "")) + + return "" + } + + e.settings = append(e.settings, slog.String(name, masked)) + + if utf8.RuneCountInString(value) < minTokenLength { + e.check(name, errShortToken) + } + + return value +} + +// logRemoteURL reads the setting that is where every log line is also +// sent. Unset or empty, it is nil, and nothing is sent. +func (e *environment) logRemoteURL(name string) *url.URL { + value := e.value(name, "") + if value == "" { + return nil + } + + remote, err := parseLogRemoteURL(value) + e.check(name, err) + + return remote +} + +// filePath reads a setting that is the path of a file, "" while it is +// unset. Its value names a file already, so, unlike the other settings, +// it has no _FILE form. Whoever uses the file reads it. +func (e *environment) filePath(name string) string { + path, _ := e.lookupEnv(name) + e.settings = append(e.settings, slog.String(name, path)) + + return path +} + +// certificates reads a setting that is the path of a file of PEM +// certificates. Unset or empty, it is nil. Its value names a file +// already, so, unlike the other settings, it has no _FILE form. +func (e *environment) certificates(name string) *x509.CertPool { + path, _ := e.lookupEnv(name) + e.settings = append(e.settings, slog.String(name, path)) + + if path == "" { + return nil + } + + pem, err := os.ReadFile(path) //nolint:gosec // a file the admin names + if err != nil { + e.check(name, fmt.Errorf("cannot be read: %w", err)) + + return nil + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + e.check(name, fmt.Errorf("%q %w", path, errNoCertificate)) + + return nil + } + + return pool +} + +// facility reads a setting that is a syslog facility, and returns its +// number. +func (e *environment) facility(name, defaultValue string) int { + number, err := parseFacility(e.value(name, defaultValue)) + e.check(name, err) + + return number +} + +// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It +// must be valid UTF-8: the metrics library panics on a label that is not. +func (e *environment) instanceName() string { + hostname, _ := os.Hostname() // "" when the host has no name to give + + value := e.value("SWW_INSTANCE_NAME", hostname) + if !utf8.ValidString(value) { + e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) + } + + return value +} + +// appName reads the setting that is the APP-NAME of the records the log +// lines are sent in, by default the instance name. Its value is checked +// when it is set, and, while lines are sent, when it is the instance name. +func (e *environment) appName( + name, instanceName string, + sending bool, +) string { + value, set := e.lookup(name) + if !set { + value = instanceName + } + + e.settings = append(e.settings, slog.String(name, value)) + + switch { + case isAppName(value): + case set: + e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) + case sending: + e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w", + value, errNotAppName)) + } + + return value +} + +// checkInstanceNameForNtfy refuses an instance name that holds a control +// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent +// the instance name in a header, which cannot hold one. +func (e *environment) checkInstanceNameForNtfy( + instanceName string, + ntfySet bool, +) { + if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { + e.check("SWW_INSTANCE_NAME", fmt.Errorf( + "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set", + instanceName, errControlCharacter)) + } +} + +// webhookURL reads a setting that is a URL each alert is posted to: +// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or +// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted +// there. The log shows ******** in place of its path and query, and an +// error shows none of it, since a webhook or an ntfy topic can carry its +// secret there. +func (e *environment) webhookURL(name string) *url.URL { + value, _ := e.lookup(name) + webhook, logged, err := parseWebhookURL(value) + e.settings = append(e.settings, slog.String(name, logged)) + e.check(name, err) + + return webhook +} + +// webhookHeaders reads the setting that is the headers sent with each +// alert. The log shows each header's value as ********, since a header +// such as Authorization carries a secret. +func (e *environment) webhookHeaders(name string) http.Header { + value, _ := e.lookup(name) + headers, logged, err := parseWebhookHeaders(value) + e.settings = append(e.settings, slog.String(name, logged)) + e.check(name, err) + + return headers +} + +// secret reads a setting that is a secret another service gave, such as +// an ntfy token or an AbuseIPDB key, "" while it is unset. It is sent in +// a header, which cannot hold a control character, so one in it is an +// error. The log shows ******** in place of a value that is not empty, and +// an error shows none of it. +func (e *environment) secret(name string) string { + value, _ := e.lookup(name) + + logged := "" + if value != "" { + logged = masked + } + + e.settings = append(e.settings, slog.String(name, logged)) + + if strings.ContainsFunc(value, unicode.IsControl) { + e.check(name, errControlCharacter) + } + + return value +} + +// alertEvents reads the setting that is the events alerts are sent for. +func (e *environment) alertEvents(name, defaultValue string) []string { + events, err := parseAlertEvents(e.value(name, defaultValue)) + e.check(name, err) + + return events +} + +// numberOrOff reads a setting that is a whole number above zero, or off, +// which is 0. +func (e *environment) numberOrOff(name, defaultValue string) int { + number, err := parseNumberOrOff(e.value(name, defaultValue)) + e.check(name, err) + + return number +} + +// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a +// whole number of days such as 7d, or off. +func parseDuration(value string) (time.Duration, error) { + if value == off { + return 0, nil + } + + duration, err := durationOrDays(value) + if err != nil { + return 0, fmt.Errorf("%q %w", value, errNotDuration) + } + + if duration <= 0 { + return 0, fmt.Errorf("%q %w", value, errNotPositive) + } + + return duration, nil +} + +// durationOrDays reads Go's duration syntax, or a whole number of days. +func durationOrDays(value string) (time.Duration, error) { + days, isDays := strings.CutSuffix(value, "d") + if !isDays { + return time.ParseDuration(value) + } + + n, err := strconv.ParseInt(days, 10, 64) + if err != nil || n < 0 || n > math.MaxInt64/int64(day) { + return 0, errNotDuration + } + + return time.Duration(n) * day, nil +} + +// parseWAFBodyLimit reads the most of a request body the Core Rule Set +// reads: a size as parseSize reads it, or off, but at most 64K, since waf +// gets no more of a body than that. +func parseWAFBodyLimit(value string) (int64, error) { + limit, err := parseSize(value) + if err != nil { + return 0, err + } + + if limit > 64*kibibyte { + return 0, fmt.Errorf("%q %w", value, errOver64K) + } + + return limit, nil +} + +// parseCount reads a whole number of requests, or off. +func parseCount(value string) (int64, error) { + if value == off { + return 0, nil + } + + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("%q %w", value, errNotCount) + } + + if n <= 0 { + return 0, fmt.Errorf("%q %w", value, errNotPositive) + } + + return n, nil +} + +// ParseDurationNotOff reads a duration above zero, as parseDuration does, +// but not off. The ban endpoint reads the duration of a ban with it too. +func ParseDurationNotOff(value string) (time.Duration, error) { + duration, err := parseDuration(value) + if err != nil || duration == 0 { + return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero) + } + + return duration, nil +} + +// parseNumberNotOff reads a whole number above zero. +func parseNumberNotOff(value string) (int, error) { + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%q %w", value, errNotNumberAboveZero) + } + + return n, nil +} + +// parseBanResponse reads how a refused client is answered: 403, 429, or +// close, which is 0. +func parseBanResponse(value string) (int, error) { + switch value { + case "403": + return http.StatusForbidden, nil + case "429": + return http.StatusTooManyRequests, nil + case "close": + return 0, nil + default: + return 0, fmt.Errorf("%q %w", value, errNotBanResponse) + } +} + +// parseV4Prefix reads the length of an IPv4 netblock, from 0 to 32. +func parseV4Prefix(value string) (int, error) { + n, err := strconv.Atoi(value) + if err != nil || n < 0 || n > ipv4Bits { + return 0, fmt.Errorf("%q %w", value, errNotV4Prefix) + } + + return n, nil +} + +// parseV6Prefix reads the length of an IPv6 netblock, from 0 to 128. +func parseV6Prefix(value string) (int, error) { + n, err := strconv.Atoi(value) + if err != nil || n < 0 || n > ipv6Bits { + return 0, fmt.Errorf("%q %w", value, errNotV6Prefix) + } + + return n, nil +} + +// parseNamedNetblocks reads a comma-separated list of named netblocks, +// each a name, = and a netblock, such as office=203.0.113.0/24. An empty +// value is an empty list. A name listed twice is an error. +func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + named := make([]anomaly.NamedNetblock, 0, len(items)) + + for _, item := range items { + name, netblockText, found := strings.Cut(item, "=") + name = strings.TrimSpace(name) + + if !found || name == "" { + return nil, fmt.Errorf("%q %w", item, errNotNamedNetblock) + } + + netblock, err := ParseNetblock(strings.TrimSpace(netblockText)) + if err != nil { + return nil, err + } + + if slices.ContainsFunc(named, func(n anomaly.NamedNetblock) bool { + return n.Name == name + }) { + return nil, fmt.Errorf("%q %w", name, errListedTwice) + } + + named = append( + named, + anomaly.NamedNetblock{Name: name, Netblock: netblock}, + ) + } + + return named, nil +} + +// parsePathPrefixes reads a comma-separated list of path prefixes, each +// starting with /. +func parsePathPrefixes(value string) ([]string, error) { + prefixes, err := parseList(value) + if err != nil { + return nil, err + } + + for _, prefix := range prefixes { + if !strings.HasPrefix(prefix, "/") { + return nil, fmt.Errorf("%q %w", prefix, errNotPathPrefix) + } + } + + return prefixes, nil +} + +// parseTrapPaths reads a comma-separated list of trap paths. Each is +// matched against a request's path as a path rule is, without the query, +// so a path that does not start with / would never match, and one that +// holds a ? is written with a query. +func parseTrapPaths(value string) ([]string, error) { + paths, err := parseList(value) + if err != nil { + return nil, err + } + + for _, path := range paths { + if !strings.HasPrefix(path, "/") || strings.Contains(path, "?") { + return nil, fmt.Errorf("%q %w", path, errNotTrapPath) + } + } + + return paths, nil +} + +// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set +// rules, each a whole number above zero and outside firstSetupRuleID to +// lastSetupRuleID. +func parseRuleIDs(value string) ([]int, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + ids := make([]int, len(items)) + + for i, item := range items { + ids[i], err = strconv.Atoi(item) + if err != nil || ids[i] <= 0 { + return nil, fmt.Errorf("%q %w", item, errNotRuleID) + } + + if ids[i] >= firstSetupRuleID && ids[i] <= lastSetupRuleID { + return nil, fmt.Errorf("%q %w", item, errSetupRuleID) + } + } + + return ids, nil +} + +// countryCodes are the two-letter codes ISO 3166-1 assigns today, and XK, +// the code in common use for Kosovo. golang.org/x/text/language cannot +// check them: it also takes withdrawn codes such as su, and reserved ones +// such as ac, as countries. +const countryCodes = ` +AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ +BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ +CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ +DE DJ DK DM DO DZ +EC EE EG EH ER ES ET +FI FJ FK FM FO FR +GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY +HK HM HN HR HT HU +ID IE IL IM IN IO IQ IR IS IT +JE JM JO JP +KE KG KH KI KM KN KP KR KW KY KZ +LA LB LC LI LK LR LS LT LU LV LY +MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ +NA NC NE NF NG NI NL NO NP NR NU NZ +OM +PA PE PF PG PH PK PL PM PN PR PS PT PW PY +QA +RE RO RS RU RW +SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ +TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ +UA UG UM US UY UZ +VA VC VE VG VI VN VU +WF WS +XK +YE YT +ZA ZM ZW +` + +// parseCountries reads a comma-separated list of country codes in either +// case, and returns them in capitals. +func parseCountries(value string) ([]string, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + countries := make([]string, 0, len(items)) + + for _, item := range items { + country, err := parseCountry(item) + if err != nil { + return nil, err + } + + countries = append(countries, country) + } + + return countries, nil +} + +// parseCountry reads a country code in either case, and returns it in +// capitals. +func parseCountry(value string) (string, error) { + country := strings.ToUpper(value) + if !slices.Contains(strings.Fields(countryCodes), country) { + return "", fmt.Errorf("%q %w", value, errNotCountry) + } + + return country, nil +} + +// ParseASN reads an AS number such as AS64496, in either case, and +// returns it as the lookup gives it: AS and the number, in capitals and +// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is +// read with it too. +func ParseASN(value string) (string, error) { + digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS") + + number, err := strconv.ParseUint(digits, 10, 32) + if !hasAS || err != nil { + return "", fmt.Errorf("%q %w", value, errNotASN) + } + + return "AS" + strconv.FormatUint(number, 10), nil +} + +// The tags a request can get that the settings can name, as waf +// writes them. The prefixes are followed by a value. +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 + // Rule Set matched. + ExploitSignatureTag = "exploitsignature" +) + +// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the +// requests of the clients it holds, and the URL it is fetched from. +type TagList struct { + Name string + URL string +} + +// parseTagLists reads a comma-separated list of name=URL pairs, such as +// torexit=https://example.com/tor.txt, and returns each list with its name +// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the +// = before http. A name or a URL listed twice is an error. +func parseTagLists(value string) ([]TagList, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + lists := make([]TagList, 0, len(items)) + + for _, item := range items { + end := strings.Index(item, "=http") + if end <= 0 || !isListURL(item[end+1:]) { + return nil, fmt.Errorf("%q %w", item, errNotTagList) + } + + list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]} + + if slices.ContainsFunc(lists, func(earlier TagList) bool { + return earlier.Name == list.Name || earlier.URL == list.URL + }) { + return nil, fmt.Errorf("%q %w", item, errListedTwice) + } + + lists = append(lists, list) + } + + return lists, nil +} + +// parseTags reads a comma-separated list of tags, which parseTag reads. A +// tag listed twice is an error, which shows the tag as MaskZoneKey does. +func parseTags(value string) ([]string, error) { + items, err := parseList(value) + if err != nil { + // parseList's error, for an empty item, shows the whole value, keys + // included. + return nil, errEmptyItem + } + + tags := make([]string, 0, len(items)) + + for _, item := range items { + tag, err := parseTag(item) + if err != nil { + return nil, err + } + + if slices.Contains(tags, tag) { + return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errListedTwice) + } + + tags = append(tags, tag) + } + + return tags, nil +} + +// 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, 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)) + + if digits, isASN := strings.CutPrefix(tag, ASNTagPrefix); isASN { + number, err := strconv.ParseUint(digits, 10, 32) + if err != nil { + return "", fmt.Errorf("%q %w", value, errNotASNTag) + } + + return ASNTagPrefix + strconv.FormatUint(number, 10), nil + } + + if code, isCountry := strings.CutPrefix(tag, CountryTagPrefix); isCountry { + country, err := parseCountry(code) + if err != nil { + return "", err + } + + 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 +} + +// parseTagPercents reads a comma-separated list of tags, each with : and a +// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed +// twice is an error, and so is ExploitSignatureTag, which a request gets +// only after the rate limits have counted it. +func parseTagPercents(value string) (map[string]int64, error) { + items, err := parseList(value) + if err != nil { + // parseList's error, for an empty item, shows the whole value, keys + // included. + return nil, errEmptyItem + } + + percents := make(map[string]int64, len(items)) + + for _, item := range items { + colon := strings.LastIndex(item, ":") + if colon <= 0 { + return nil, fmt.Errorf("%q %w", MaskZoneKey(item), errNotTagPercentItem) + } + + tag, err := parseTag(item[:colon]) + if err != nil { + return nil, err + } + + percent, err := strconv.ParseInt(item[colon+1:], 10, 64) + if err != nil || percent < 1 || percent > 99 { + return nil, fmt.Errorf("%q %w", MaskZoneKey(item[colon+1:]), errNotTagPercent) + } + + if tag == ExploitSignatureTag { + return nil, fmt.Errorf("%q %w", tag, errExploitTag) + } + + if _, listed := percents[tag]; listed { + // tag, since item[:colon] may end in a space, which MaskZoneKey + // would not mask. + return nil, fmt.Errorf("%q %w", MaskZoneKey(tag), errListedTwice) + } + + percents[tag] = percent + } + + return percents, nil +} + +// parsePercents reads a comma-separated list of items, each an AS number +// or a country, which parseCode reads, then : and a percentage, such as +// AS64496:50 or cn:25, and returns each one's percentage. An empty value +// is an empty list. An AS number or country listed twice is an error. +func parsePercents( + value string, parseCode func(string) (string, error), +) (map[string]int64, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + percents := make(map[string]int64, len(items)) + + for _, item := range items { + codeText, percentText, found := strings.Cut(item, ":") + if !found { + return nil, fmt.Errorf("%q %w", item, errNotPercentItem) + } + + code, err := parseCode(codeText) + if err != nil { + return nil, err + } + + percent, err := ParsePercent(percentText) + if err != nil { + return nil, err + } + + if _, listed := percents[code]; listed { + return nil, fmt.Errorf("%q %w", codeText, errListedTwice) + } + + percents[code] = percent + } + + return percents, nil +} + +// ParsePercent reads a percentage, a whole number from 0 to 100. The file +// SWW_ASN_LIMIT_PERCENT_URL names is read with it too. +func ParsePercent(value string) (int64, error) { + percent, err := strconv.ParseInt(value, 10, 64) + if err != nil || percent < 0 || percent > 100 { + return 0, fmt.Errorf("%q %w", value, errNotPercent) + } + + return percent, nil +} + +// headerNameChars are the characters RFC 9110 allows in a header name: +// letters, digits and these marks. +const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + "0123456789!#$%&'*+-.^_`|~" + +// IsHeaderName reports whether name can be a header name: one or more of +// the characters RFC 9110 allows in one. +func IsHeaderName(name string) bool { + if name == "" { + return false + } + + for _, char := range name { + if !strings.ContainsRune(headerNameChars, char) { + return false + } + } + + return true +} + +// parseHeaderNames reads a comma-separated list of header names in either +// case, and returns them in lower case. Host and Transfer-Encoding are +// refused: Go's HTTP server takes them out of the request's headers. +func parseHeaderNames(value string) ([]string, error) { + items, err := parseList(value) + if err != nil { + return nil, err + } + + headers := make([]string, 0, len(items)) + + for _, item := range items { + if !IsHeaderName(item) { + return nil, fmt.Errorf("%q %w", item, errNotHeaderName) + } + + header := strings.ToLower(item) + switch header { + case "host": + return nil, fmt.Errorf( + "%q %w; the request's host is the field host", + item, + errHeaderTakenOut, + ) + case "transfer-encoding": + return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) + } + + headers = append(headers, header) + } + + return headers, nil +} + +// parseLogRemoteURL reads where every log line is also sent: +// syslog+udp, syslog+tcp or syslog+tls, a host and a port from 1 to +// 65535, and nothing else. +func parseLogRemoteURL(value string) (*url.URL, error) { + remote, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) + } + + schemes := []string{ + remotelog.SchemeUDP, + remotelog.SchemeTCP, + remotelog.SchemeTLS, + } + port, err := strconv.ParseUint(remote.Port(), 10, 16) + + onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && + remote.Hostname() != "" && err == nil && port != 0 && + remote.User == nil && remote.Opaque == "" && + (remote.Path == "" || remote.Path == "/") && + remote.RawQuery == "" && remote.Fragment == "" + if !onlySchemeHostAndPort { + return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) + } + + return remote, nil +} + +// parseFacility reads the name of a syslog facility, and returns its +// number, as RFC 5424 numbers them. +func parseFacility(value string) (int, error) { + //nolint:mnd // the facilities' numbers in RFC 5424 + number, known := map[string]int{ + "kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5, + "lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11, + "local0": 16, "local1": 17, "local2": 18, "local3": 19, + "local4": 20, "local5": 21, "local6": 22, "local7": 23, + }[value] + if !known { + return 0, fmt.Errorf("%q %w", value, errNotFacility) + } + + return number, nil +} + +// parseWebhookURL reads where each alert is posted: http or https, a +// host, and an optional port from 1 to 65535, path and query, without a +// user or a fragment. It returns the URL, and how the log shows it: its +// scheme and host, and ******** in place of its path and query, if it has +// either. An error shows no part of the value. An empty value is no URL. +func parseWebhookURL(value string) (*url.URL, string, error) { + if value == "" { + return nil, "", nil + } + + webhook, err := url.Parse(value) + if err != nil || !isHTTPURL(webhook) { + return nil, "", errNotWebhookURL + } + + logged := webhook.Scheme + "://" + webhook.Host + if webhook.Path != "" || webhook.RawQuery != "" { + logged += "/" + masked + } + + return webhook, logged, nil +} + +// isHTTPURL reports whether u is http or https, with a host, and an +// optional port from 1 to 65535, path and query, without a user or a +// fragment. +func isHTTPURL(u *url.URL) bool { + port, err := strconv.ParseUint(u.Port(), 10, 16) + + return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && + (u.Port() == "" || (err == nil && port != 0)) && + u.User == nil && + u.Opaque == "" && + u.Fragment == "" +} + +// isListURL reports whether value is a URL a list can be fetched from, as +// isHTTPURL says. +func isListURL(value string) bool { + u, err := url.Parse(value) + + return err == nil && isHTTPURL(u) +} + +// parseListURLs reads a comma-separated list of the URLs lists are fetched +// from. A URL listed twice is an error: it would be fetched twice as +// often. +func parseListURLs(value string) ([]string, error) { + urls, err := parseList(value) + if err != nil { + return nil, err + } + + for i, listURL := range urls { + if !isListURL(listURL) { + return nil, fmt.Errorf("%q %w", listURL, errNotListURL) + } + + if slices.Contains(urls[:i], listURL) { + return nil, fmt.Errorf("%q %w", listURL, errListedTwice) + } + } + + return urls, nil +} + +const ( + // maxZoneLength is the most characters a DNSBL zone may have: 253, the + // most a DNS name may have, less the 64 that come before the zone in + // the name an IPv6 client is asked about by, its 32 hex digits each + // followed by a dot. + maxZoneLength = 189 + // maxLabelLength is the most characters a label of a DNS name may have. + maxLabelLength = 63 + // labelChars are the characters a label of a DNS zone may hold. + labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" + // dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER + // gives none. + dnsPort = 53 +) + +// parseZones reads a comma-separated list of DNSBL zones, each a DNS name +// such as dnsbl.dronebl.org: labels separated by dots, each of 1 to 63 +// letters, digits and hyphens, neither starting nor ending with a hyphen, +// and at most maxZoneLength characters in all. Go's resolver takes any +// other name for one that does not exist, so that the zone would list no +// client. A zone listed twice is an error, whatever the case of its +// letters, which DNS names ignore, and whatever its key, since +// MaskZoneKey shows two keys of one zone alike. An error shows a zone as +// MaskZoneKey does. +func parseZones(value string) ([]string, error) { + zones, err := parseList(value) + if err != nil { + // parseList's error, for an empty item, shows the whole value, keys + // included. + return nil, errEmptyItem + } + + for i, zone := range zones { + shown := MaskZoneKey(zone) + listedBefore := slices.ContainsFunc( + zones[:i], + func(earlier string) bool { + return strings.EqualFold(MaskZoneKey(earlier), shown) + }, + ) + + switch { + case len(zone) > maxZoneLength: + return nil, fmt.Errorf("%q %w", shown, errZoneTooLong) + case !isZone(zone): + return nil, fmt.Errorf("%q %w", shown, errNotZone) + case listedBefore: + return nil, fmt.Errorf("%q %w", shown, errListedTwice) + } + } + + return zones, nil +} + +// MaskZoneKey returns zone with ******** in place of its key, if it is a +// zone of Spamhaus's keyed query service, a name under dq.spamhaus.net, +// such as .xbl.dq.spamhaus.net, whose first label is the key. Any +// other zone it returns as it is. A zone is shown so wherever it leaves +// the process: in the log, the alerts and the metrics. +func MaskZoneKey(zone string) string { + // DNS names ignore case, and a name may be written with a dot at its + // end. + name := strings.TrimSuffix(strings.ToLower(zone), ".") + if !strings.HasSuffix(name, ".dq.spamhaus.net") { + return zone + } + + _, rest, _ := strings.Cut(zone, ".") + + return masked + "." + rest +} + +// isZone reports whether each label of zone is as parseZones takes it. +func isZone(zone string) bool { + for label := range strings.SplitSeq(zone, ".") { + badChar := strings.ContainsFunc(label, func(char rune) bool { + return !strings.ContainsRune(labelChars, char) + }) + + if label == "" || len(label) > maxLabelLength || badChar || + strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return false + } + } + + return true +} + +// parseResolver reads the resolver the DNSBL zones are asked through: an +// IP address with a port from 1 to 65535, such as 192.0.2.53:5353 or +// [2001:db8::53]:5353, or without one, such as 192.0.2.53 or 2001:db8::53, +// for port 53. An empty value is none, the zero AddrPort. +func parseResolver(value string) (netip.AddrPort, error) { + if value == "" { + return netip.AddrPort{}, nil + } + + resolver, err := netip.ParseAddrPort(value) + if err == nil && resolver.Port() != 0 { + return resolver, nil + } + + addr, err := netip.ParseAddr(value) + if err != nil { + return netip.AddrPort{}, fmt.Errorf("%q %w", value, errNotResolver) + } + + return netip.AddrPortFrom(addr, dnsPort), nil +} + +// parseWebhookHeaders reads a comma-separated list of headers, each its +// name, :, and its value, and returns them, and how the log shows them, +// with each value as ********. An error names the item by its place in +// the list, so that it shows no value. An empty value is an empty list. +func parseWebhookHeaders(value string) (http.Header, string, error) { + headers := http.Header{} + if strings.TrimSpace(value) == "" { + return headers, "", nil + } + + logged := []string{} + + for i, item := range strings.Split(value, ",") { + name, headerValue, found := strings.Cut(item, ":") + name = strings.TrimSpace(name) + + if !found || !IsHeaderName(name) || + strings.ContainsAny(headerValue, "\r\n\x00") { + return nil, "", fmt.Errorf( + "item %d %w", + i+1, + errNotWebhookHeader, + ) + } + + headers.Add(name, strings.TrimSpace(headerValue)) + logged = append(logged, name+":"+masked) + } + + return headers, strings.Join(logged, ","), nil +} + +// parseAlertEvents reads a comma-separated list of the events alerts can +// be sent for. +func parseAlertEvents(value string) ([]string, error) { + events, err := parseList(value) + if err != nil { + return nil, err + } + + for _, event := range events { + if !slices.Contains(alerts.Events(), event) { + return nil, fmt.Errorf("%q %w", event, errNotAlertEvent) + } + } + + return events, nil +} + +// parseNumberOrOff reads a whole number above zero, or off, which is 0. +func parseNumberOrOff(value string) (int, error) { + if value == off { + return 0, nil + } + + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%q %w", value, errNotNumberOrOff) + } + + return n, nil +} + +// appNameMaxLength is the most characters RFC 5424 allows in an +// APP-NAME. +const appNameMaxLength = 48 + +// isAppName reports whether value can be an APP-NAME: 1 to +// appNameMaxLength printable ASCII characters, none of them a space. +func isAppName(value string) bool { + if value == "" || len(value) > appNameMaxLength { + return false + } + + for _, char := range []byte(value) { + if char < '!' || char > '~' { + return false + } + } + + return true +} diff --git a/internal/config/config_test.go b/internal/config/waf_test.go similarity index 100% rename from internal/config/config_test.go rename to internal/config/waf_test.go -- 2.54.0