// Package config reads smallwebwaf's settings. Every setting is an // environment variable whose name starts with SWWAF_, or a file such a // variable names, every setting has a default, and this package is the // one place they are read. package config import ( "crypto/x509" "errors" "fmt" "log/slog" "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 smallwebwaf's settings. A timeout, size or rate limit of zero // is off. type Config struct { // ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR). ListenAddr string // UpstreamURL is the app (SWWAF_UPSTREAM_URL). UpstreamURL *url.URL // InstanceName is the name every log line and alert gives as instance, // and every metric carries as its label instance (SWWAF_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 SWWAF_MODE is observe rather // than enforce: a request that SWWAF_DENY_NETS, a ban, the country // lists, a rate limit or a rule would refuse is passed to the app // instead, and no ban is made. Observe bool // TrustedProxies are the netblocks whose X-Forwarded-For is // believed (SWWAF_TRUSTED_PROXIES). TrustedProxies []netip.Prefix // ClientRequestTimeout bounds reading the whole request from the // client (SWWAF_CLIENT_REQUEST_TIMEOUT). ClientRequestTimeout time.Duration // ClientRequestHeaderMaxBytes is the largest request line and headers // a client may send (SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES). It is // never off, and always more than 4K. ClientRequestHeaderMaxBytes int64 // ClientIdleTimeout bounds how long a kept-open client connection // may wait for its next request (SWWAF_CLIENT_IDLE_TIMEOUT). ClientIdleTimeout time.Duration // ClientResponseTimeout bounds writing the whole response to the // client (SWWAF_CLIENT_RESPONSE_TIMEOUT). ClientResponseTimeout time.Duration // UpstreamRequestTimeout bounds connecting to the app and writing // the whole request to it (SWWAF_UPSTREAM_REQUEST_TIMEOUT). UpstreamRequestTimeout time.Duration // UpstreamResponseTimeout bounds reading the whole response from // the app (SWWAF_UPSTREAM_RESPONSE_TIMEOUT). UpstreamResponseTimeout time.Duration // RequestMaxBytes is the largest request body // (SWWAF_REQUEST_MAX_BYTES). RequestMaxBytes int64 // ResponseMaxBytes is the largest response body // (SWWAF_RESPONSE_MAX_BYTES). ResponseMaxBytes int64 // AllowNets are the netblocks whose clients skip every check // (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the // rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS). // DenyNets are those whose clients are always refused // (SWWAF_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 // (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and // SWWAF_RATE_LIMIT_PER_DAY). RateLimitPerMinute int64 RateLimitPerHour int64 RateLimitPerDay int64 // RateLimitExemptPaths are the path prefixes whose requests the rate // limits neither count nor refuse (SWWAF_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 (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and // SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count // toward them (SWWAF_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 (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. // LookupDBPath is the lookup database, the IPinfo Lite file looked up // in while LookupSource is file (SWWAF_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 // (SWWAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true // when the app is passed the client's AS number and country in headers // (SWWAF_ADD_LOOKUP_HEADERS). LookupSource string LookupDBPath string LookupTimeout time.Duration AddLookupHeaders bool // DeniedCountries are the countries whose clients are refused // (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not // empty, are the only countries whose clients are let through // (SWWAF_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 // (SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT). // ASNBytesPercent and CountryBytesPercent give those they list a // percentage of the byte limits in place of that one // (SWWAF_ASN_BYTES_PERCENT and SWWAF_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 (SWWAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercent map[string]int64 CountryLimitPercent map[string]int64 ASNBytesPercent map[string]int64 CountryBytesPercent map[string]int64 UnknownLimitPercent int64 // BanResponse is the status a refused client is answered with, 403 // or 429, or 0 to close the connection without an answer // (SWWAF_BAN_RESPONSE). It answers a banned client, a request that // breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the // country lists. BanResponse int // LimitBanDuration is the ban for a first broken rate limit // (SWWAF_LIMIT_BAN_DURATION). A limit broken again within // LimitBanRepeatWindow after the last ban ended bans for three times // as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that // would be longer than MaxBanDuration is permanent instead // (SWWAF_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 // (SWWAF_ATTACK_BAN_DURATION). It cannot be off. AttackBanDuration time.Duration // MaxBans is the most bans held (SWWAF_MAX_BANS). MaxBans int // BanScopeV4Prefix is the length of the netblock around an IPv4 // client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX). BanScopeV4Prefix int // StateDir is the directory of the state files, an absolute path // (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban // is made (SWWAF_STATE_WRITE_DELAY), and every state file every // StateCounterInterval (SWWAF_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 (SWWAF_LOG_REQUEST_HEADERS). LogRequestHeaders []string // AdminToken is the bearer token an admin sends for the ban endpoints // and /_smallwebwaf/clients/ (SWWAF_ADMIN_TOKEN), "" while it is // unset and they are off. AdminToken string // MetricsToken is the bearer token a scraper sends for the metrics // (SWWAF_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 (SWWAF_METRICS_TOP_N). MetricsToken string MetricsTopN int // RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read // unless RulesEnabled is false (SWWAF_RULES_ENABLED). RulesDir string RulesEnabled bool // LogRemoteURL is where every line on stdout is also sent // (SWWAF_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 (SWWAF_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 (SWWAF_LOG_REMOTE_BUFFER). // LogRemoteFacility is the number of the syslog facility // (SWWAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME // (SWWAF_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 // (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders // are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). // AlertSlackWebhookURL is the Slack incoming webhook each alert is // posted to as a message (SWWAF_ALERT_SLACK_WEBHOOK_URL), and // AlertNtfyURL the ntfy topic each is published to // (SWWAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, // unless empty, is sent to ntfy with each (SWWAF_ALERT_NTFY_TOKEN). // With none of the three URLs set, no alert is sent. AlertEvents are // the events alerts are sent for (SWWAF_ALERT_EVENTS). A repeat of an // alert within AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and // so is an alert past AlertMaxPerHour in an hour, for the hour's // summary (SWWAF_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 (SWWAF_ANOMALY_CLIENT_*), // per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number // (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*) // and per named netblock (SWWAF_WATCH_*), each 0 while it is off. // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the // netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and // SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks // (SWWAF_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" // fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the // lookup database, the file SWWAF_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 // minTokenLength is the fewest characters a token may have. minTokenLength = 32 // masked is what the log shows for a token that is set, and in place of // a secret in another setting. masked = "********" // defaultListenAddr and defaultUpstreamURL are the defaults of // SWWAF_LISTEN_ADDR and SWWAF_UPSTREAM_URL. defaultListenAddr = ":8080" defaultUpstreamURL = "http://127.0.0.1:8081" ) 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 SWWAF_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") errDBPathUnused = errors.New("only file reads it") errNotOver4K = errors.New("is not a size of more than 4K, such as 32K") 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") 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/smallwebwaf") 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/") 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 gitea") 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/smallwebwaf") 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, waf_block, 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") ) // 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("SWWAF_LISTEN_ADDR", defaultListenAddr), UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL), InstanceName: env.instanceName(), Observe: env.observe("SWWAF_MODE", "enforce"), TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges), ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"), ClientRequestHeaderMaxBytes: env.headerSize( "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"), ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"), ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"), UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"), UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"), RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"), RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "10G"), BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"), BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"), AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), ExclusivelyAllowedCountries: env.countries( "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", parseASN), CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry), ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", parseASN), CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry), UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"), BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", "accept,accept-language,accept-encoding,content-type,origin,range"), AdminToken: env.token("SWWAF_ADMIN_TOKEN"), MetricsToken: env.token("SWWAF_METRICS_TOKEN"), MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"), LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"), LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"), AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"), AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"), AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", strings.Join(alerts.Events(), ",")), AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"), AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), AnomalyWatch: env.thresholds("SWWAF_WATCH_"), AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"), AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"), WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), } cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME", cfg.InstanceName, cfg.LogRemoteURL != nil) env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) env.checkLookupDBPath(cfg) env.checkCountriesAndLookups(cfg) if env.err != nil { return nil, env.err } cfg.settings = env.settings return cfg, nil } // ListenAddrAndUpstreamURL reads only SWWAF_LISTEN_ADDR and // SWWAF_UPSTREAM_URL, either of which may be given as a file, as // FromEnvironment does. The health check needs no other setting, so it // reads no other, nor a file that another names. func ListenAddrAndUpstreamURL( lookupEnv func(string) (string, bool), ) (string, *url.URL, error) { env := &environment{lookupEnv: lookupEnv} listenAddr := env.address("SWWAF_LISTEN_ADDR", defaultListenAddr) upstreamURL := env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL) if env.err != nil { return "", nil, env.err } return listenAddr, upstreamURL, nil } // InstanceName reads only SWWAF_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 { lookupEnv func(string) (string, bool) settings []slog.Attr err error } // 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 { value, set := e.lookup(name) if !set { value = defaultValue } e.settings = append(e.settings, slog.String(name, value)) return value } // lookup returns a setting's value and whether it is set: the value of the // variable name, or the contents of the file that the variable name_FILE // 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) fileName := name + "_FILE" path, inFile := e.lookupEnv(fileName) if !inFile { return value, set } if set { e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice)) return value, set } e.settings = append(e.settings, slog.String(fileName, path)) contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names if err != nil { e.check(fileName, fmt.Errorf("cannot be read: %w", err)) return "", false } return strings.TrimSuffix(string(contents), "\n"), true } // check keeps the first error, naming the setting it is about. func (e *environment) check(name string, err error) { if err != nil && e.err == nil { e.err = fmt.Errorf("%s: %w", name, err) } } // address reads a setting that is an address to listen on. func (e *environment) address(name, defaultValue string) string { address, err := parseListenAddr(e.value(name, defaultValue)) e.check(name, err) 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)) e.check(name, err) 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)) e.check(name, err) return size } // headerSize reads the setting that is the largest request line and // headers. func (e *environment) headerSize(name, defaultValue string) int64 { size, err := parseHeaderSize(e.value(name, defaultValue)) e.check(name, err) return size } // 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 } // 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 } // 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 } // 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 SWWAF_LOOKUP_SOURCE=file without // SWWAF_LOOKUP_DB_PATH, and SWWAF_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("SWWAF_LOOKUP_SOURCE", fmt.Errorf( "is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( "is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) } } // checkCountriesAndLookups refuses a country on both country lists, and, // while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked // up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds, // of which SWWAF_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("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", fmt.Errorf("%q %w", country, errOnBothLists)) } } if cfg.LookupSource != off { return } for _, setting := range []struct { name string set bool }{ {"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0}, {"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, {"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, {"SWWAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0}, {"SWWAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0}, {"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, {"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, {"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, {"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, {"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, {"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, {"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, } { if setting.set { e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", errNeedsLookups)) } } } // 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 } // 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 } // 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 SWWAF_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("SWWAF_INSTANCE_NAME", hostname) if !utf8.ValidString(value) { e.check("SWWAF_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 SWWAF_INSTANCE_NAME %q, its default, %w", value, errNotAppName)) } return value } // checkInstanceNameForNtfy refuses an instance name that holds a control // character while ntfySet, SWWAF_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("SWWAF_INSTANCE_NAME", fmt.Errorf( "%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", instanceName, errControlCharacter)) } } // webhookURL reads a setting that is a URL each alert is posted to: // SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or // SWWAF_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, "" 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) { if value == off { return 0, nil } number, unit := splitUnit(value) n, err := strconv.ParseInt(number, 10, 64) if err != nil || n > math.MaxInt64/unit { return 0, fmt.Errorf("%q %w", value, errNotSize) } if n <= 0 { return 0, fmt.Errorf("%q %w", value, errNotPositive) } return n * unit, nil } // parseHeaderSize reads the largest request line and headers: a size as // parseSize reads it, but more than 4K and never off. Go's server reads 4K // past the limit it is given before it refuses, so proxy.New gives it this // size less 4K, which must leave a limit. func parseHeaderSize(value string) (int64, error) { size, err := parseSize(value) if err != nil || size <= 4*kibibyte { return 0, fmt.Errorf("%q %w", value, errNotOver4K) } return size, nil } // splitUnit splits a size into its number and the bytes its suffix // stands for. func splitUnit(value string) (string, int64) { switch { case strings.HasSuffix(value, "K"): return strings.TrimSuffix(value, "K"), kibibyte case strings.HasSuffix(value, "M"): return strings.TrimSuffix(value, "M"), mebibyte case strings.HasSuffix(value, "G"): return strings.TrimSuffix(value, "G"), gibibyte default: return value, 1 } } // 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) { if strings.TrimSpace(value) == "" { return []string{}, nil } items := strings.Split(value, ",") for i, item := range items { items[i] = strings.TrimSpace(item) if items[i] == "" { return nil, fmt.Errorf("%q %w", value, errEmptyItem) } } return items, nil } // parseNetblocks reads a comma-separated list of netblocks. func parseNetblocks(value string) ([]netip.Prefix, error) { items, err := parseList(value) if err != nil { return nil, err } netblocks := make([]netip.Prefix, 0, len(items)) for _, item := range items { netblock, err := parseNetblock(item) if err != nil { return nil, err } netblocks = append(netblocks, netblock) } return netblocks, nil } // parseNetblock reads a netblock in CIDR form, such as 10.0.0.0/8. A bare // address is a netblock of that address alone, a /32 or a /128. func parseNetblock(value string) (netip.Prefix, error) { if strings.Contains(value, "/") { netblock, err := netip.ParsePrefix(value) if err != nil { return netip.Prefix{}, fmt.Errorf("%q %w", value, errNotNetblock) } return netblock.Masked(), nil } addr, err := netip.ParseAddr(value) if err != nil || addr.Zone() != "" { return netip.Prefix{}, fmt.Errorf("%q %w", value, errNotNetblock) } 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 } // 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. 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 } // 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. 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) { _, port, err := net.SplitHostPort(value) if err != nil { return "", fmt.Errorf("%q %w", value, errNotListenAddr) } _, err = strconv.ParseUint(port, 10, 16) if err != nil { return "", fmt.Errorf("%q %w", value, errNotListenAddr) } 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 { return nil, "", errNotWebhookURL } port, err := strconv.ParseUint(webhook.Port(), 10, 16) valid := (webhook.Scheme == "http" || webhook.Scheme == "https") && webhook.Hostname() != "" && (webhook.Port() == "" || (err == nil && port != 0)) && webhook.User == nil && webhook.Opaque == "" && webhook.Fragment == "" if !valid { return nil, "", errNotWebhookURL } logged := webhook.Scheme + "://" + webhook.Host if webhook.Path != "" || webhook.RawQuery != "" { logged += "/" + masked } return webhook, logged, 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 }