Read every setting through viper and rename the settings (closes #211)
check / check (push) Failing after 3m27s

Every setting of discriminator and rproxy is read through viper, in place
of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv
and AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

The settings are renamed. Discriminator's take the prefix SWW_WAF_,
rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core
Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_.
Every place a name appeared changed: the code, the viper keys, tests,
documents, examples, the README's settings table and the image files.

Model: deepseek-v4-pro
This commit is contained in:
sneak committed 2026-11-07 06:41:11 +00:00
1 parent beb503b233
commit 68da92f414
89 files changed
+2027 -1916

No files matched your search

+273 -254
View File
@@ -1,10 +1,9 @@
// Package config reads sww's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of `sww
// discriminator` start with SWW_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
// the image runs.
// discriminator` start with SWW_WAF_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWW_RPROXY_. Each
// of them but SWW_RPROXY_WAF_BACKEND has a default, and each can be given as a file
// too. SWW_COMMAND chooses which of the two the image runs.
package config
import (
@@ -26,6 +25,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
@@ -34,85 +35,85 @@ import (
// Config is the settings of `sww discriminator`. A time or size limit of
// zero is off.
type Config struct {
// ListenAddr is where discriminator listens (SWW_LISTEN_ADDR).
// ListenAddr is where discriminator listens (SWW_WAF_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),
// and every metric carries as its label instance (SWW_WAF_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
// Observe is true in observe mode, when SWW_WAF_MODE is observe rather
// than enforce: a request that SWW_WAF_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.
// (SWW_WAF_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).
// memory and in clients.json (SWW_WAF_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).
// (SWW_WAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_NETS).
// DenyNets are those whose clients are always refused
// (SWW_DENY_NETS).
// (SWW_WAF_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).
// (SWW_WAF_RATE_LIMIT_PER_MINUTE, SWW_WAF_RATE_LIMIT_PER_HOUR and
// SWW_WAF_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).
// limits neither count nor refuse (SWW_WAF_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.
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWW_WAF_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.
// looked up (SWW_WAF_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
// in while LookupSource is file (SWW_WAF_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
// (SWW_WAF_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).
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
// DeniedCountries are the countries whose clients are refused
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// (SWW_WAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// empty, are the only countries whose clients are let through
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them.
DeniedCountries []string
ExclusivelyAllowedCountries []string
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
// the clients of the AS numbers and the countries they list that
// percentage of every rate limit and byte limit
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
// (SWW_WAF_ASN_LIMIT_PERCENT and SWW_WAF_COUNTRY_LIMIT_PERCENT).
// ASNBytesPercent and CountryBytesPercent give those they list a
// percentage of the byte limits in place of that one
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_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
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
// count as those of ASNLimitPercent do (SWW_WAF_ASN_LIMIT_PERCENT_URL), ""
// while it is unset.
ASNLimitPercent map[string]int64
CountryLimitPercent map[string]int64
@@ -121,30 +122,30 @@ type Config struct {
UnknownLimitPercent int64
ASNLimitPercentURL string
// BlocklistURLs are where the blocklists are fetched from
// (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// (SWW_WAF_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.
// (SWW_WAF_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,
// (SWW_WAF_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
// (SWW_WAF_DNSBL_ZONES), through DNSBLResolver (SWW_WAF_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
// with (SWW_WAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
// at most AbuseIPDBDailyBudget checks are made a day
// (SWW_ABUSEIPDB_DAILY_BUDGET).
// (SWW_WAF_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;
// or whose score is a hit (SWW_WAF_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.
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
@@ -155,74 +156,74 @@ type Config struct {
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:
// engine whose local API SWW_WAF_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).
// (SWW_WAF_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 and the
// (SWW_WAF_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWW_WAF_DENY_NETS and the
// country lists.
BanResponse int
// LimitBanDuration is the ban for a first broken rate limit
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
// (SWW_WAF_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
// as long as that ban (SWW_WAF_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.
// (SWW_WAF_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.
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
// MaxBans is the most bans held (SWW_MAX_BANS).
// MaxBans is the most bans held (SWW_WAF_MAX_BANS).
MaxBans int
// BanScopeV4Prefix is the length of the netblock around an IPv4
// client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX).
// client that a ban covers (SWW_WAF_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 file every
// StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state file every
// StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither can be
// off.
StateDir string
StateWriteDelay time.Duration
StateCounterInterval time.Duration
// LogRequestHeaders are the request headers whose values the request
// log gives, in lower case (SWW_LOG_REQUEST_HEADERS).
// log gives, in lower case (SWW_WAF_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.
// written (SWW_WAF_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/<ip> (SWW_ADMIN_TOKEN), "" while it is
// and /_sww/clients/<ip> (SWW_WAF_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.
// (SWW_WAF_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).
// of their own in the metrics (SWW_WAF_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 is the directory of the rule files (SWW_WAF_RULES_DIR), read
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff,
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
// level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_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),
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_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.
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWW_WAF_CORE_RULE_SET_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.
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string
WAFParanoiaLevel int
WAFAnomalyThreshold int
@@ -231,21 +232,21 @@ type Config struct {
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 ?.
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute
// that discriminator may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
// more breaks a limit (SWW_WAF_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.
// (SWW_WAF_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
// certificate must chain to (SWW_WAF_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).
// lines held while they wait to be sent (SWW_WAF_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
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// the lines are sent in.
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
@@ -253,18 +254,18 @@ type Config struct {
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).
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
// posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and
// posted to as a message (SWW_WAF_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).
// (SWW_WAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
// unless empty, is sent to ntfy with each (SWW_WAF_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
// the events alerts are sent for (SWW_WAF_ALERT_EVENTS). A repeat of an
// alert within AlertCooldown is held back (SWW_WAF_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.
// summary (SWW_WAF_ALERT_MAX_PER_HOUR); 0 is off for both.
AlertWebhookURL *url.URL
AlertWebhookHeaders http.Header
AlertSlackWebhookURL *url.URL
@@ -274,14 +275,14 @@ type Config struct {
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.
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
// and per named netblock (SWW_WAF_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).
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWW_WAF_WATCH_NETS).
AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds
@@ -300,7 +301,7 @@ type Config struct {
// off.
const off = "off"
// The values of SWW_WAF_MODE.
// The values of SWW_WAF_CORE_RULE_SET_MODE.
const (
// WAFModeOff runs no request through the Core Rule Set.
WAFModeOff = off
@@ -310,8 +311,8 @@ const (
WAFModeBlock = "block"
)
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
// lookup database, the file SWW_LOOKUP_DB_PATH names.
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
// lookup database, the file SWW_WAF_LOOKUP_DB_PATH names.
const fileSource = "file"
const (
@@ -321,7 +322,7 @@ const (
gibibyte = 1 << 30
ipv4Bits = 32
ipv6Bits = 128
// minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the
// minIPv6GroupPrefix is the shortest SWW_WAF_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
@@ -338,8 +339,8 @@ const (
// 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 is the default of SWW_WAF_LISTEN_ADDR and
// SWW_RPROXY_LISTEN_ADDR, and defaultUpstreamURL that of SWW_RPROXY_UPSTREAM_URL.
defaultListenAddr = ":8080"
defaultUpstreamURL = "http://127.0.0.1:8081"
)
@@ -367,7 +368,7 @@ var (
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_DENIED_COUNTRIES too")
errOnBothLists = errors.New("is in SWW_WAF_DENIED_COUNTRIES 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")
@@ -435,7 +436,7 @@ var (
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")
errInBlocklistURLs = errors.New("is in SWW_WAF_BLOCKLIST_URLS too")
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log")
@@ -450,117 +451,118 @@ var (
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")
"is in SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too")
)
// 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.
// FromEnvironment reads the settings through v, normally the viper
// NewViper returns, bound to the process's environment. 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}
func FromEnvironment(v *viper.Viper) (*Config, error) {
env := &environment{v: v, readFiles: true}
cfg := &Config{
ListenAddr: env.address("SWW_LISTEN_ADDR"),
ListenAddr: env.address("SWW_WAF_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"),
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
Observe: env.observe("SWW_WAF_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_WAF_IPV6_GROUP_PREFIX", "64"),
MaxTrackedClients: env.numberNotOff("SWW_WAF_MAX_TRACKED_CLIENTS", "20000"),
AllowNets: env.netblocks("SWW_WAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWW_WAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWW_WAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWW_WAF_RATE_LIMIT_PER_MINUTE", "120"),
RateLimitPerHour: env.count("SWW_WAF_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWW_WAF_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes("SWW_WAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWW_WAF_BYTES_LIMIT_PER_MINUTE", "off"),
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWW_WAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_WAF_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWW_WAF_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries(
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
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"),
LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS",
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWW_WAF_ASN_LIMIT_PERCENT", ParseASN),
CountryLimitPercent: env.percents("SWW_WAF_COUNTRY_LIMIT_PERCENT", parseCountry),
ASNBytesPercent: env.percents("SWW_WAF_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_WAF_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_WAF_UNKNOWN_LIMIT_PERCENT", "100"),
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
BlocklistURLs: env.listURLs("SWW_WAF_BLOCKLIST_URLS"),
BlocklistRefresh: env.refresh("SWW_WAF_BLOCKLIST_REFRESH", "24h"),
DNSBLZones: env.zones("SWW_WAF_DNSBL_ZONES"),
DNSBLResolver: env.resolver("SWW_WAF_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWW_WAF_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWW_WAF_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff("SWW_WAF_ABUSEIPDB_DAILY_BUDGET", "900"),
ReputationCacheTTL: env.durationNotOff("SWW_WAF_REPUTATION_CACHE_TTL", "24h"),
ReputationTimeout: env.durationNotOff("SWW_WAF_REPUTATION_TIMEOUT", "2s"),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_WAF_CROWDSEC_LAPI_URL"),
CrowdSecKey: env.secret("SWW_WAF_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWW_WAF_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff("SWW_WAF_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWW_WAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWW_WAF_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWW_WAF_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWW_WAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWW_WAF_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWW_WAF_STATE_DIR", "/var/lib/sww"),
StateWriteDelay: env.durationNotOff("SWW_WAF_STATE_WRITE_DELAY", "10s"),
StateCounterInterval: env.durationNotOff("SWW_WAF_STATE_COUNTER_INTERVAL", "15m"),
LogRequestHeaders: env.headerNames("SWW_WAF_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",
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWW_WAF_CORE_RULE_SET_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL", "1"),
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", "5"),
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
WAFExemptPaths: env.pathPrefixes("SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_CORE_RULE_SET_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",
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_WAF_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates("SWW_WAF_LOG_REMOTE_TLS_CA_FILE"),
LogRemoteBuffer: env.numberNotOff("SWW_WAF_LOG_REMOTE_BUFFER", "10000"),
LogRemoteFacility: env.facility("SWW_WAF_LOG_REMOTE_FACILITY", "local0"),
AlertWebhookURL: env.webhookURL("SWW_WAF_ALERT_WEBHOOK_URL"),
AlertWebhookHeaders: env.webhookHeaders("SWW_WAF_ALERT_WEBHOOK_HEADERS"),
AlertSlackWebhookURL: env.webhookURL("SWW_WAF_ALERT_SLACK_WEBHOOK_URL"),
AlertNtfyURL: env.webhookURL("SWW_WAF_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWW_WAF_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWW_WAF_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"),
AlertCooldown: env.duration("SWW_WAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWW_WAF_ALERT_MAX_PER_HOUR", "60"),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix("SWW_WAF_ANOMALY_NET_V4_PREFIX", "24"),
AnomalyNetV6Prefix: env.v6Prefix("SWW_WAF_ANOMALY_NET_V6_PREFIX", "48"),
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
}
cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME",
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
cfg.InstanceName, cfg.LogRemoteURL != nil)
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
"SWW_BLOCKLIST_ACTION", "deny")
"SWW_WAF_BLOCKLIST_ACTION", "deny")
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
"SWW_REPUTATION_ACTION", "limit:25")
"SWW_WAF_REPUTATION_ACTION", "limit:25")
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
env.checkLookupDBPath(cfg)
@@ -580,8 +582,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
// Command reads only SWW_COMMAND, the command the image runs and its
// health check checks: rproxy when it is unset or empty, or discriminator. It cannot
// be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) {
command, _ := lookupEnv("SWW_COMMAND")
func Command(v *viper.Viper) (string, error) {
command, _ := readSetting(v, "SWW_COMMAND")
switch command {
case "", "rproxy":
@@ -593,22 +595,22 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
}
}
// DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
// DiscriminatorListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in discriminator's container needs
// no other setting, so it reads no other, nor a file that another names.
func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWW_LISTEN_ADDR")
func DiscriminatorListenAddr(v *viper.Viper) (string, error) {
env := &environment{v: v, readFiles: true}
listenAddr := env.address("SWW_WAF_LISTEN_ADDR")
return listenAddr, env.err
}
// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a
// InstanceName reads only SWW_WAF_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}
func InstanceName(v *viper.Viper) string {
env := &environment{v: v, readFiles: true}
return env.instanceName()
}
@@ -624,13 +626,26 @@ func (c *Config) LogValue() slog.Value {
}
// environment is where FromEnvironment reads the settings: it notes each
// value for the log, and keeps the first error.
// value for the log, and keeps the first error. It reads every setting
// through v, the viper NewViper returns, and, unless readFiles is false,
// honours each setting's _FILE form too.
type environment struct {
lookupEnv func(string) (string, bool)
v *viper.Viper
readFiles bool
settings []slog.Attr
err error
}
// readSetting returns a setting's value and whether it is set, reading it
// through v.
func readSetting(v *viper.Viper, name string) (string, bool) {
if !v.IsSet(name) {
return "", false
}
return v.GetString(name), true
}
// value returns a setting's value, or its default when it is not set,
// and notes it for the log.
func (e *environment) value(name, defaultValue string) string {
@@ -649,10 +664,14 @@ func (e *environment) value(name, defaultValue string) string {
// names, less one newline at their end. It notes that file's path for the
// log. Both variables set, or a file that cannot be read, is an error.
func (e *environment) lookup(name string) (string, bool) {
value, set := e.lookupEnv(name)
value, set := readSetting(e.v, name)
if !e.readFiles {
return value, set
}
fileName := name + "_FILE"
path, inFile := e.lookupEnv(fileName)
path, inFile := readSetting(e.v, fileName)
if !inFile {
return value, set
}
@@ -961,30 +980,30 @@ func (e *environment) lookupSource(name, defaultValue string) string {
return source
}
// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without
// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source:
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_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))
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWW_WAF_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))
e.check("SWW_WAF_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWW_WAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
}
}
// checkCountriesAndLookups refuses a country on both country lists, and,
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, 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
// while SWW_WAF_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWW_WAF_ADD_LOOKUP_HEADERS, the biased thresholds,
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_WAF_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.DeniedCountries, country) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists))
}
}
@@ -997,53 +1016,53 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string
set bool
}{
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"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},
{"SWW_WAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_WAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_WAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWW_WAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
{"SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
{"SWW_WAF_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",
e.check(setting.name, fmt.Errorf("is set while SWW_WAF_LOOKUP_SOURCE is off; %w",
errNeedsLookups))
}
}
}
// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a
// checkASNLimitPercentURL refuses SWW_WAF_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",
e.check("SWW_WAF_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
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
// SWW_WAF_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))
e.check("SWW_WAF_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWW_WAF_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))
e.check("SWW_WAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWW_WAF_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))
e.check("SWW_WAF_CROWDSEC_LAPI_URL",
fmt.Errorf("gives the decision list %q, which %w", decisionsURL, errAnotherList))
}
}
@@ -1198,7 +1217,7 @@ func (e *environment) logRemoteURL(name string) *url.URL {
// 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)
path, _ := readSetting(e.v, name)
e.settings = append(e.settings, slog.String(name, path))
return path
@@ -1208,7 +1227,7 @@ func (e *environment) filePath(name string) string {
// 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)
path, _ := readSetting(e.v, name)
e.settings = append(e.settings, slog.String(name, path))
if path == "" {
@@ -1241,14 +1260,14 @@ func (e *environment) facility(name, defaultValue string) int {
return number
}
// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It
// instanceName reads SWW_WAF_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)
value := e.value("SWW_WAF_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) {
e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
}
return value
@@ -1270,7 +1289,7 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
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",
e.check(name, fmt.Errorf("is unset, and SWW_WAF_INSTANCE_NAME %q, its default, %w",
value, errNotAppName))
}
@@ -1278,19 +1297,19 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
}
// checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
// character while ntfySet, SWW_WAF_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",
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWW_WAF_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
// SWW_WAF_ALERT_WEBHOOK_URL, SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
// SWW_WAF_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.
@@ -1739,7 +1758,7 @@ func parseCountry(value string) (string, error) {
// 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
// without leading zeros. The file SWW_WAF_ASN_LIMIT_PERCENT_URL names is
// read with it too.
func ParseASN(value string) (string, error) {
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
@@ -1793,7 +1812,7 @@ func parsePercents(
}
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
// SWW_ASN_LIMIT_PERCENT_URL names is read with it too.
// SWW_WAF_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 {
@@ -2013,7 +2032,7 @@ const (
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
// dnsPort is the port a resolver is asked on when SWW_WAF_DNSBL_RESOLVER
// gives none.
dnsPort = 53
)
+141 -135
View File
@@ -17,105 +17,107 @@ import (
"testing"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// The settings, by name.
const (
listenAddr = "SWW_LISTEN_ADDR"
mode = "SWW_MODE"
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_BYTES_COUNT"
lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
blocklistAction = "SWW_BLOCKLIST_ACTION"
dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWW_DNSBL_RESOLVER"
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_MAX_BAN_DURATION"
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
maxBans = "SWW_MAX_BANS"
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_METRICS_TOP_N"
instanceName = "SWW_INSTANCE_NAME"
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
logLevel = "SWW_LOG_LEVEL"
rulesDir = "SWW_RULES_DIR"
rulesEnabled = "SWW_RULES_ENABLED"
wafMode = "SWW_WAF_MODE"
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL"
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWW_ALERT_NTFY_URL"
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWW_ALERT_EVENTS"
alertCooldown = "SWW_ALERT_COOLDOWN"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS"
listenAddr = "SWW_WAF_LISTEN_ADDR"
mode = "SWW_WAF_MODE"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_WAF_BYTES_COUNT"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_WAF_DENIED_COUNTRIES"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWW_WAF_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_WAF_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH"
blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWW_WAF_REPUTATION_ACTION"
reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT"
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_WAF_METRICS_TOP_N"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
logLevel = "SWW_WAF_LOG_LEVEL"
rulesDir = "SWW_WAF_RULES_DIR"
rulesEnabled = "SWW_WAF_RULES_ENABLED"
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL"
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWW_WAF_TRAP_PATHS"
errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_WAF_LOG_REMOTE_URL"
logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE"
logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER"
logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY"
logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME"
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS"
alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL"
alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWW_WAF_ALERT_EVENTS"
alertCooldown = "SWW_WAF_ALERT_COOLDOWN"
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
)
// The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends.
const (
anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_"
watch = "SWW_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
watch = "SWW_WAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -156,21 +158,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged
}
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
// defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN.
const (
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
"source_failure,file_error"
defaultAlertCooldown = "15m"
)
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
// defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
// defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
// defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range"
@@ -195,8 +197,8 @@ const (
otherToken = "fedcba9876543210fedcba9876543210"
)
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
// instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines.
const (
instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514"
@@ -208,7 +210,7 @@ const off = "off"
// enabled is true, as a setting's value.
const enabled = "true"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database.
const (
defaultLookupSource = "geojs"
@@ -218,18 +220,22 @@ const (
// environment is a set of environment variables, for FromEnvironment.
type environment map[string]string
// lookupEnv reads one of the variables, as os.LookupEnv does.
func (e environment) lookupEnv(name string) (string, bool) {
value, ok := e[name]
// viper returns a viper Set with each of the variables, as the viper
// NewViper returns one bound to a process's environment.
func (e environment) viper() *viper.Viper {
v := viper.New()
for name, value := range e {
v.Set(name, value)
}
return value, ok
return v
}
// fromEnvironment reads the settings from env, which must be valid.
func fromEnvironment(t *testing.T, env environment) *config.Config {
t.Helper()
cfg, err := config.FromEnvironment(env.lookupEnv)
cfg, err := config.FromEnvironment(env.viper())
if err != nil {
t.Fatalf("settings %v: %v", env, err)
}
@@ -456,7 +462,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv)
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
`starting with /, such as /assets/`
@@ -511,7 +517,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -640,7 +646,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -765,7 +771,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
} {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
}
@@ -782,7 +788,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err)
}
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
if err == nil || err.Error() != want {
@@ -797,7 +803,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
_, err := config.FromEnvironment(sending.lookupEnv)
_, err := config.FromEnvironment(sending.viper())
want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
@@ -827,7 +833,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
logRemoteURL: remoteURL,
instanceName: instance,
logRemoteAppName: "my app",
}.lookupEnv)
}.viper())
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog`
@@ -958,7 +964,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
// An item that is not a header is named by its place, not shown.
_, err := config.FromEnvironment(environment{
alertWebhookHeaders: "X-Team:ops," + secret,
}.lookupEnv)
}.viper())
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
"and the header's value, such as Authorization:Bearer <token>"
@@ -995,7 +1001,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
"ftp://hooks.example/services/" + secret,
"https://hooks.example/services/%zz" + secret,
} {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/sww"
@@ -1046,7 +1052,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
"set": {alertNtfyToken: token + "\r"},
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
} {
_, err := config.FromEnvironment(env.lookupEnv)
_, err := config.FromEnvironment(env.viper())
want := alertNtfyToken + ": holds a control character, such as the " +
"carriage return of a Windows line end"
@@ -1063,7 +1069,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
_, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv)
}.viper())
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
@@ -1088,7 +1094,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
"set": {instanceName: latin1},
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
} {
_, err := config.FromEnvironment(env.lookupEnv)
_, err := config.FromEnvironment(env.viper())
want := instanceName + `: "caf\xe9" is not valid UTF-8`
if err == nil || err.Error() != want {
@@ -1103,7 +1109,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru",
allowedCountries: "de,RU",
}.lookupEnv)
}.viper())
if err == nil {
t.Fatal("ru on both country lists was accepted")
}
@@ -1165,7 +1171,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
},
} {
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want {
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
}
@@ -1195,7 +1201,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
_, err := config.FromEnvironment(environment{
lookupSource: off, name: value,
}.lookupEnv)
}.viper())
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
!strings.Contains(err.Error(), lookupSource+" is off") {
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
@@ -1204,7 +1210,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
}
// Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, any other anomaly threshold, or
// a blocklist.
env := environment{
@@ -1345,7 +1351,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1424,7 +1430,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1442,7 +1448,7 @@ const (
asnURL = "https://lists.example/asn.txt"
)
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
// The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them.
const (
actionDeny = "deny"
actionLimit = "limit"
@@ -1521,7 +1527,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1536,9 +1542,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv)
}.viper())
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
@@ -1663,7 +1669,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1731,7 +1737,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
{spamhaus + ",,", "has an empty item in its list"},
} {
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
want := dnsblZones + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1784,7 +1790,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1841,7 +1847,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
`SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too`
)
for _, tc := range []struct {
@@ -1898,7 +1904,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want {
t.Errorf("error %v, want %s", err, tc.want)
}
@@ -2058,7 +2064,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil {
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
}
@@ -2083,7 +2089,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
t.Run(value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
if err == nil || err.Error() != logRequestHeaders+": "+want {
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
}
@@ -2100,7 +2106,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
t.Run(name+"="+value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
_, err := config.FromEnvironment(environment{name: value}.viper())
want := name + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
@@ -2156,7 +2162,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
_, err := config.FromEnvironment(environment{
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv)
}.viper())
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want {
@@ -2167,7 +2173,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
logRemoteURL: remoteURL,
instanceName: instance,
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
}.lookupEnv)
}.viper())
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog`
@@ -2182,7 +2188,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
metricsToken: token,
metricsToken + "_FILE": writeFile(t, token),
}.lookupEnv)
}.viper())
want := metricsToken + ": is set, and so is " + metricsToken +
"_FILE; set only one of them"
@@ -2197,7 +2203,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
want := metricsToken + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
+37 -48
View File
@@ -8,38 +8,39 @@ import (
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
)
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
// is off.
type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
// ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR).
ListenAddr string
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
// UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
UpstreamURL *url.URL
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES).
// (SWW_RPROXY_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
// WAFBackend is discriminator's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for discriminator's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request when discriminator cannot be
// connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION).
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64
MaxResponseSize int64
// MaxRequestTime bounds sending the whole request: the visitor's request
// line and headers, and then, from their end, its body on its way to the
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// the end of the request to its last byte reaching the visitor
// (SWRP_MAX_RESPONSE_TIME_MS).
// (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
MaxRequestTime time.Duration
MaxResponseTime time.Duration
@@ -58,24 +59,26 @@ var (
"is not a whole number of milliseconds above zero, such as 60000, or off")
)
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
// os.LookupEnv. A setting that is not set takes its default, and
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
// invalid is an error that names it. Since rproxy never touches disk, its
// settings have no _FILE form.
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
// RProxyFromEnvironment reads rproxy's settings through v, normally the viper
// NewViper returns, bound to the process's environment. A setting that is not
// set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
// set. 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. A setting
// that is set but invalid is an error that names it.
func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
env := &environment{v: v, readFiles: true}
cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff(
"SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
}
if env.err != nil {
@@ -87,15 +90,15 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
return cfg, nil
}
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
// RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
// SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool),
v *viper.Viper,
) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
listenAddr := env.address("SWRP_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
env := &environment{v: v, readFiles: true}
listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil {
return "", nil, env.err
@@ -104,20 +107,6 @@ func RProxyListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil
}
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
// rproxy never touches disk.
func withoutFileForms(
lookupEnv func(string) (string, bool),
) func(string) (string, bool) {
return func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}
}
// LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default.
func (c *RProxy) LogValue() slog.Value {
+41 -28
View File
@@ -9,21 +9,23 @@ import (
"testing"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// rproxy's settings, by name, and their values in these tests.
const (
wafBackend = "SWRP_WAF_BACKEND"
rproxyTimeout = "SWRP_TIMEOUT_MS"
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
rproxyListen = "SWRP_LISTEN_ADDR"
rproxyUpstream = "SWRP_UPSTREAM_URL"
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://discriminator:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
@@ -32,11 +34,12 @@ const (
// rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := env[name]
v := viper.New()
for name, value := range env {
v.Set(name, value)
}
return value, ok
})
return config.RProxyFromEnvironment(v)
}
func TestRProxyDefaults(t *testing.T) {
@@ -177,31 +180,41 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
}
}
func TestRProxyReadsNoFile(t *testing.T) {
func TestRProxySettingReadFromFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "backend")
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
if err != nil {
t.Fatalf("write: %v", err)
}
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want %s still required", err, wafBackend)
}
cfg, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": "/nonexistent/timeout",
})
cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err != nil {
t.Fatalf("settings: %v, with the file left unread", err)
t.Fatalf("settings: %v", err)
}
if cfg.Timeout != 2*time.Second {
t.Errorf("timeout %v, want the default", cfg.Timeout)
if cfg.WAFBackend.String() != backendURL {
t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL)
}
}
func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "github.com/spf13/viper"
// NewViper returns the viper that reads the settings: each setting's
// environment variable, bound to its own name. Every setting of discriminator
// and rproxy is read through it, and none is read with os.Getenv or
// os.LookupEnv outside it. An environment variable that is set but empty is
// set, as one that holds a value is, which viper does not take for granted.
func NewViper() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
v.AllowEmptyEnv(true)
return v
}