rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Successful in 17m6s
check / check (push) Successful in 17m6s
`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:
+201
-96
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,73 +20,68 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
// shortTimeout is what a test sets a timeout to, to see it run out.
|
||||
// It starts before the test has set up its case, such as an upgrade
|
||||
// or the app's buffers filling, so it is as long as the hold-up of the
|
||||
// test process that wantTimedOut allows: a shorter one can run out
|
||||
// first on a busy host.
|
||||
shortTimeout = waitLimit / 2
|
||||
// longTimeoutSetting is a timeout that does not run out in a test.
|
||||
longTimeoutSetting = "1m"
|
||||
// waitLimit bounds how long a test waits for what should happen.
|
||||
waitLimit = 10 * time.Second
|
||||
// pollInterval is how often a test looks for a log line.
|
||||
pollInterval = 10 * time.Millisecond
|
||||
// localhost is where every test server listens, and so the address
|
||||
// smallwebwaf sees each test's requests come from.
|
||||
// localhost is where every test server listens, and so the address rproxy
|
||||
// sees each test's requests come from.
|
||||
localhost = "127.0.0.1"
|
||||
// requestType is the type that marks a request log line.
|
||||
requestType = "request"
|
||||
// protocol is the protocol of every test's requests.
|
||||
// protocol is the protocol of every question.
|
||||
protocol = "HTTP/1.1"
|
||||
// trustLocalhost trusts the address every test connects from, and a
|
||||
// network for proxies in front of it.
|
||||
trustLocalhost = localhost + "/32,10.0.0.0/8"
|
||||
// appHost is the host every test asks for.
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the one
|
||||
// rproxy serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot call String
|
||||
var shortTimeoutSetting = shortTimeout.String()
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
||||
mode = "SWWAF_MODE"
|
||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
mode = "SWWAF_MODE"
|
||||
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
)
|
||||
|
||||
// off is the value that switches a setting off.
|
||||
@@ -179,6 +175,73 @@ func (o *output) requestLine(t *testing.T) logLine {
|
||||
return o.requestLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// endedLines waits for count requests that went through rproxy, or to servewaf's
|
||||
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||
// order servewaf took them. A question's last line is the one its report writes.
|
||||
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
found := o.lastLines(t)
|
||||
|
||||
ended := len(found) >= count
|
||||
for _, line := range found[:min(count, len(found))] {
|
||||
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
|
||||
ended = false
|
||||
}
|
||||
}
|
||||
|
||||
if ended {
|
||||
return found
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("fewer than %d requests have ended after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lastLines returns the last request log line written so far of each request,
|
||||
// in the order servewaf took them.
|
||||
func (o *output) lastLines(t *testing.T) []logLine {
|
||||
t.Helper()
|
||||
|
||||
var found []logLine
|
||||
|
||||
places := map[string]int{}
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] != requestType {
|
||||
continue
|
||||
}
|
||||
|
||||
line := decodeLine(t, fields)
|
||||
|
||||
place, seen := places[line.RequestID]
|
||||
if seen {
|
||||
found[place] = line
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
places[line.RequestID] = len(found)
|
||||
found = append(found, line)
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// endedLine waits for a test's one request to have ended, and returns its last
|
||||
// log line.
|
||||
func (o *output) endedLine(t *testing.T) logLine {
|
||||
t.Helper()
|
||||
|
||||
return o.endedLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// decodeLine reads a request log line's fields into a logLine.
|
||||
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||
t.Helper()
|
||||
@@ -198,7 +261,7 @@ func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||
return line
|
||||
}
|
||||
|
||||
// startApp starts app as the app smallwebwaf passes requests to.
|
||||
// startApp starts app as the app rproxy passes requests to.
|
||||
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -208,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
// startProxy starts smallwebwaf in front of the app at appURL, with the
|
||||
// settings in env on top of the defaults, and returns where it listens and
|
||||
// what it writes.
|
||||
// startProxy starts servewaf, and rproxy in front of the app at appURL, each with
|
||||
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||
// and what servewaf writes.
|
||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
@@ -230,10 +293,10 @@ func startProxyWithGeoJS(
|
||||
}
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns the server as well. Unless
|
||||
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
|
||||
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of
|
||||
// every metric.
|
||||
// bans made by the time now tells, and returns servewaf's server as well, whose
|
||||
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
|
||||
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
|
||||
// the label instance of every metric.
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
@@ -246,14 +309,70 @@ func startProxyWithClock(
|
||||
}
|
||||
|
||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||
// the alerts the proxy raises as well, as newProxy makes them.
|
||||
// the alerts servewaf raises as well, as newServeWAF makes them.
|
||||
func startProxyWithAlerts(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env)
|
||||
server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
|
||||
serve(t, server)
|
||||
|
||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||
}
|
||||
|
||||
// serve serves server on a port of its own until the test ends, and sets its
|
||||
// Addr to where it listens.
|
||||
func serve(t *testing.T, server *proxy.Server) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
server.Addr = listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
|
||||
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
|
||||
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||
func startRProxy(
|
||||
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
|
||||
"SWRP_UPSTREAM_URL": appURL,
|
||||
"SWRP_TIMEOUT_MS": "60000",
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||
value, ok := settings[name]
|
||||
|
||||
return value, ok
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings %v: %v", settings, err)
|
||||
}
|
||||
|
||||
server := rproxy.New(rproxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: io.Discard,
|
||||
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
|
||||
})
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
@@ -268,38 +387,35 @@ func startProxyWithAlerts(
|
||||
_ = server.Close()
|
||||
})
|
||||
|
||||
return listener.Addr().String(), out, server, alertQueue
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
// newProxy makes the server startProxyWithClock starts, without starting
|
||||
// it, and returns it, what it writes, and the queue of the alerts the
|
||||
// proxy raises, as the settings in env make it. No alert is sent from the
|
||||
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
||||
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
|
||||
// is off unless env sets it. While it is file, the lookup database
|
||||
// SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off
|
||||
// unless env sets it, so that only the tests of the Core Rule Set have
|
||||
// their requests inspected by it.
|
||||
func newProxy(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
// newServeWAF makes servewaf's server, without starting it, and returns it, what
|
||||
// it writes, and the queue of the alerts it raises, as the settings in env make
|
||||
// it. No alert is sent from the queue: they wait in it, for the test to look at.
|
||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
|
||||
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||
// inspected by it.
|
||||
func newServeWAF(
|
||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newServerWith(t, proxy.New, appURL, geojsURL, now, env)
|
||||
return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newServerWith is newProxy for the server build makes.
|
||||
func newServerWith(
|
||||
// newServeWAFWith is newServeWAF for the server build makes.
|
||||
func newServeWAFWith(
|
||||
t *testing.T, build func(proxy.Params) *proxy.Server,
|
||||
appURL, geojsURL string, now func() time.Time, env map[string]string,
|
||||
geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||
wafMode: off,
|
||||
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
||||
}
|
||||
if geojsURL == "" {
|
||||
settings[lookupSource] = off
|
||||
@@ -391,7 +507,7 @@ func readAnswer(res *http.Response) answer {
|
||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||
}
|
||||
|
||||
// newRequest makes a request for path to smallwebwaf at addr.
|
||||
// newRequest makes a request for path to addr, where rproxy or servewaf listens.
|
||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -415,15 +531,15 @@ func do(t *testing.T, req *http.Request) answer {
|
||||
return readAnswer(res)
|
||||
}
|
||||
|
||||
// get sends a GET request for path to smallwebwaf at addr.
|
||||
// get sends a GET request for path to addr.
|
||||
func get(t *testing.T, addr, path string) answer {
|
||||
t.Helper()
|
||||
|
||||
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
||||
}
|
||||
|
||||
// dial opens a connection to smallwebwaf at addr, for requests the HTTP
|
||||
// client cannot make, such as one that stops sending halfway.
|
||||
// dial opens a connection to addr, for requests the HTTP client cannot make,
|
||||
// such as one that stops sending halfway.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
@@ -484,14 +600,3 @@ func wantStatus(t *testing.T, got answer, status int) {
|
||||
t.Errorf("status %d, want %d", got.status, status)
|
||||
}
|
||||
}
|
||||
|
||||
// wantTimedOut checks that what began at start ended once shortTimeout
|
||||
// had run out, and not much later.
|
||||
func wantTimedOut(t *testing.T, start time.Time) {
|
||||
t.Helper()
|
||||
|
||||
took := time.Since(start)
|
||||
if took < shortTimeout || took > shortTimeout+waitLimit/2 {
|
||||
t.Errorf("took %s, want %s", took, shortTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user