package config import ( "errors" "fmt" "log/slog" "math" "net/netip" "net/url" "strconv" "strings" "time" ) // RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero // is off. type RProxy struct { // ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). ListenAddr string // UpstreamURL is the app (SWRP_UPSTREAM_URL). UpstreamURL *url.URL // TrustedProxies are the netblocks whose X-Forwarded-For is believed // (SWRP_TRUSTED_PROXIES). TrustedProxies []netip.Prefix // WAFBackend is servewaf's URL (SWRP_WAF_BACKEND). WAFBackend *url.URL // Timeout is how long rproxy waits for servewaf's answer to a question, // and to a report (SWRP_TIMEOUT_MS). It cannot be off. Timeout time.Duration // DefaultDisposition is what is done with a request servewaf gives no // answer about, allow or deny (SWRP_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 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 // the end of the request to its last byte reaching the visitor // (SWRP_MAX_RESPONSE_TIME_MS). MaxRequestTime time.Duration MaxResponseTime time.Duration // settings are the values read, as given or by default, for the log line // at start. settings []slog.Attr } var ( errNeedsWAFBackend = errors.New( "is required: it is servewaf's URL, such as http://servewaf:8080") errNotDisposition = errors.New("is not allow or deny") errNotMilliseconds = errors.New( "is not a whole number of milliseconds above zero, such as 2000") errNotMillisecondsOrOff = errors.New( "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: func(name string) (string, bool) { if strings.HasSuffix(name, "_FILE") { return "", false } return lookupEnv(name) }} cfg := &RProxy{ ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr), 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"), } if env.err != nil { return nil, env.err } cfg.settings = env.settings return cfg, nil } // 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 { return slog.GroupValue(c.settings...) } // wafBackend reads the setting that is servewaf's URL, which must be set. func (e *environment) wafBackend(name string) *url.URL { value := e.value(name, "") if value == "" { e.check(name, errNeedsWAFBackend) return nil } backend, err := parseUpstreamURL(value) e.check(name, err) return backend } // disposition reads the setting that is what is done with a request servewaf // gives no answer about: allow or deny. func (e *environment) disposition(name, defaultValue string) string { value := e.value(name, defaultValue) if value != "allow" && value != "deny" { e.check(name, fmt.Errorf("%q %w", value, errNotDisposition)) } return value } // milliseconds reads a setting that is a whole number of milliseconds above // zero. func (e *environment) milliseconds(name, defaultValue string) time.Duration { value := e.value(name, defaultValue) duration, valid := parseMilliseconds(value) if !valid { e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds)) } return duration } // millisecondsOrOff reads a setting that is a whole number of milliseconds // above zero, or off, which is 0. func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration { value := e.value(name, defaultValue) if value == off { return 0 } duration, valid := parseMilliseconds(value) if !valid { e.check(name, fmt.Errorf("%q %w", value, errNotMillisecondsOrOff)) } return duration } // parseMilliseconds reads a whole number of milliseconds above zero, and // reports whether it is one. func parseMilliseconds(value string) (time.Duration, bool) { n, err := strconv.ParseInt(value, 10, 64) if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) { return 0, false } return time.Duration(n) * time.Millisecond, true }