check / check (push) Waiting to run
Applies the setting renames of the naming audit under the SWW_WAF_ and SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache time and the two time limits, which now take milliseconds. The rproxy log field default_disposition becomes waf_backend_no_answer, and config.Config and config.FromEnvironment become config.WAF and config.WAFFromEnvironment. Go fields, test names and prose follow. No aliases; nothing reads the old names. Model: opus-5-5
252 lines
7.6 KiB
Go
252 lines
7.6 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"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 (SWW_RPROXY_LISTEN_ADDR).
|
|
ListenAddr string
|
|
// UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
|
|
UpstreamURL *url.URL
|
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
|
// (SWW_RPROXY_TRUSTED_PROXIES).
|
|
TrustedProxies []netip.Prefix
|
|
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
|
|
WAFBackend *url.URL
|
|
// WAFBackendTimeout is how long rproxy waits for waf's answer to a
|
|
// question, and to a report (SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS). It
|
|
// cannot be off.
|
|
WAFBackendTimeout time.Duration
|
|
// WAFBackendNoAnswerDisposition is what is done with a request when waf
|
|
// cannot be connected to, does not answer at all, or does not answer
|
|
// within WAFBackendTimeout: allow or deny
|
|
// (SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION).
|
|
WAFBackendNoAnswerDisposition string
|
|
// 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 (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
|
// the end of the request to its last byte reaching the visitor
|
|
// (SWW_RPROXY_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
|
|
}
|
|
|
|
// defaultUpstreamURL is the default of SWW_RPROXY_UPSTREAM_URL.
|
|
const defaultUpstreamURL = "http://127.0.0.1:8081"
|
|
|
|
// privateRanges are the private address ranges, the default trusted
|
|
// proxies.
|
|
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
|
|
var (
|
|
errNotUpstreamURL = errors.New(
|
|
"is not a URL with only a scheme, a host and an optional port, " +
|
|
"such as http://127.0.0.1:8081")
|
|
errNeedsWAFBackend = errors.New(
|
|
"is required: it is waf's URL, such as http://waf:8080")
|
|
errNotDisposition = errors.New("is not allow or deny")
|
|
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 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}
|
|
cfg := &RProxy{
|
|
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"),
|
|
WAFBackendTimeout: env.milliseconds(
|
|
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS",
|
|
"2000",
|
|
),
|
|
WAFBackendNoAnswerDisposition: env.disposition(
|
|
"SWW_RPROXY_WAF_BACKEND_NO_ANSWER_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 {
|
|
return nil, env.err
|
|
}
|
|
|
|
cfg.settings = env.settings
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// 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(
|
|
v *viper.Viper,
|
|
) (string, *url.URL, error) {
|
|
env := &environment{v: v}
|
|
listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
|
|
upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
|
|
|
|
if env.err != nil {
|
|
return "", nil, env.err
|
|
}
|
|
|
|
return listenAddr, upstreamURL, 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...)
|
|
}
|
|
|
|
// appURL reads a setting that is the app's URL.
|
|
func (e *environment) appURL(name, defaultValue string) *url.URL {
|
|
upstream, err := parseUpstreamURL(e.value(name, defaultValue))
|
|
e.check(name, err)
|
|
|
|
return upstream
|
|
}
|
|
|
|
// wafBackend reads the setting that is waf's URL, which must be set.
|
|
func (e *environment) wafBackend(name string) *url.URL {
|
|
value := e.value(name, "")
|
|
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 when waf
|
|
// cannot be connected to, does not answer at all, or does not answer in time:
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|