rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 8m17s
check / check (push) Failing after 8m17s
`smallwebwaf` with no command now prints the commands and exits with status 2. The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The passing-on code, its settings and its log fields and metric go. internal/proxy keeps the checks, answers, reports and own endpoints; its tests run through the real rproxy. The documents describe the pair. Judgement call: clients.json keeps token_refused, since state files refuse unknown fields. Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests. Model: opus-5-5
This commit is contained in:
+68
-121
@@ -1,9 +1,10 @@
|
||||
// Package config reads smallwebwaf's settings, and is the one place they are
|
||||
// read. Every setting is an environment variable. The settings of
|
||||
// `smallwebwaf` and `smallwebwaf servewaf` start with SWWAF_, every one of them
|
||||
// has a default, and most can be given as a file instead. Those of `smallwebwaf
|
||||
// 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.
|
||||
// read. Every setting is an environment variable. The settings of `smallwebwaf
|
||||
// servewaf` start with SWWAF_, every one of them has a default, and most can be
|
||||
// given as a file instead. Those of `smallwebwaf 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. SMALLWEBWAF_COMMAND chooses which of the two
|
||||
// the image runs.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -30,13 +31,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
|
||||
// is off.
|
||||
// Config is the settings of `smallwebwaf servewaf`. A time or size limit of
|
||||
// zero is off.
|
||||
type Config struct {
|
||||
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR).
|
||||
// ListenAddr is where servewaf 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
|
||||
@@ -45,42 +44,14 @@ type Config struct {
|
||||
// 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, a rule or the Core Rule Set would refuse is
|
||||
// passed to the app instead, and no ban is made.
|
||||
// allowed instead, and no ban is made.
|
||||
Observe bool
|
||||
// TrustedProxies are the netblocks whose X-Forwarded-For is
|
||||
// believed (SWWAF_TRUSTED_PROXIES).
|
||||
TrustedProxies []netip.Prefix
|
||||
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
||||
// (SWWAF_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 (SWWAF_MAX_TRACKED_CLIENTS).
|
||||
MaxTrackedClients int
|
||||
// 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).
|
||||
@@ -260,9 +231,8 @@ type Config struct {
|
||||
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||
// that smallwebwaf may refuse after a rule file or Core Rule Set match
|
||||
// or for a missing or wrong token; one more breaks a limit
|
||||
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
// that servewaf may refuse after a rule file or Core Rule Set match; one
|
||||
// more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
ErrorBurstThreshold int64
|
||||
// LogRemoteURL is where every line on stdout is also sent
|
||||
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||
@@ -365,8 +335,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 and defaultUpstreamURL are the defaults of
|
||||
// SWWAF_LISTEN_ADDR and SWWAF_UPSTREAM_URL.
|
||||
// defaultListenAddr is the default of SWWAF_LISTEN_ADDR and
|
||||
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
|
||||
defaultListenAddr = ":8080"
|
||||
defaultUpstreamURL = "http://127.0.0.1:8081"
|
||||
)
|
||||
@@ -394,13 +364,14 @@ 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 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")
|
||||
errOver1G = errors.New("is more than 1G, the most Coraza reads")
|
||||
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")
|
||||
errOver64K = errors.New(
|
||||
"is more than 64K, the most servewaf gets of a body")
|
||||
errNotCommand = errors.New("is not rproxy or servewaf")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d")
|
||||
errNotNumberAboveZero = errors.New(
|
||||
@@ -489,38 +460,27 @@ var (
|
||||
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),
|
||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
|
||||
InstanceName: env.instanceName(),
|
||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
||||
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
|
||||
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
|
||||
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", "1G"),
|
||||
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", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
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", "800ms"),
|
||||
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
|
||||
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
||||
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", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
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", "800ms"),
|
||||
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),
|
||||
@@ -612,22 +572,30 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
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)
|
||||
// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its
|
||||
// health check checks: rproxy when it is unset or empty, or servewaf. 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("SMALLWEBWAF_COMMAND")
|
||||
|
||||
if env.err != nil {
|
||||
return "", nil, env.err
|
||||
switch command {
|
||||
case "", "rproxy":
|
||||
return "rproxy", nil
|
||||
case "servewaf":
|
||||
return command, nil
|
||||
default:
|
||||
return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand)
|
||||
}
|
||||
}
|
||||
|
||||
return listenAddr, upstreamURL, nil
|
||||
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
|
||||
// file, as FromEnvironment does. The health check in servewaf's container needs
|
||||
// no other setting, so it reads no other, nor a file that another names.
|
||||
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
listenAddr := env.address("SWWAF_LISTEN_ADDR")
|
||||
|
||||
return listenAddr, env.err
|
||||
}
|
||||
|
||||
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
|
||||
@@ -709,9 +677,10 @@ func (e *environment) check(name string, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// address reads a setting that is an address to listen on, by default
|
||||
// defaultListenAddr.
|
||||
func (e *environment) address(name string) string {
|
||||
address, err := parseListenAddr(e.value(name, defaultListenAddr))
|
||||
e.check(name, err)
|
||||
|
||||
return address
|
||||
@@ -779,15 +748,6 @@ func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
||||
return limit
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -1434,30 +1394,17 @@ func parseSize(value string) (int64, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
||||
// reads: a size as parseSize reads it, or off, but at most 1G, since
|
||||
// Coraza, which runs the Core Rule Set, refuses to load with more.
|
||||
// reads: a size as parseSize reads it, or off, but at most 64K, since servewaf
|
||||
// gets no more of a body than that.
|
||||
func parseWAFBodyLimit(value string) (int64, error) {
|
||||
limit, err := parseSize(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if limit > gibibyte {
|
||||
return 0, fmt.Errorf("%q %w", value, errOver1G)
|
||||
if limit > 64*kibibyte {
|
||||
return 0, fmt.Errorf("%q %w", value, errOver64K)
|
||||
}
|
||||
|
||||
return limit, nil
|
||||
|
||||
Reference in New Issue
Block a user