Read every setting through viper and rename the settings (closes #211)
check / check (push) Failing after 1m52s

Every setting of waf and rproxy is read through viper, in place of the
os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and
AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take
SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's
settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_.

Model: deepseek-v4-pro (first version); opus-5-5 (rework)
This commit is contained in:
sneak authored and clawbot committed 2026-11-12 04:13:28 +00:00
1 parent de7ef16001
commit 0de82da1a8
130 files changed
+2451 -2266

No files matched your search

+127 -123
View File
@@ -20,6 +20,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
@@ -34,22 +35,22 @@ const (
testVersion = "test"
// localhost is where the tests listen.
localhost = "127.0.0.1"
listenAddr = "SWW_LISTEN_ADDR"
stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_CACHE_DIR"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_RULES_DIR"
lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH"
instanceName = "SWW_INSTANCE_NAME"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set.
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_WAF_RULES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWW_INSTANCE_NAME the tests set where they look at
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
// it.
instance = "fsn1app1/blog"
// greeting is what the tests' app answers.
@@ -154,26 +155,24 @@ func (o *output) text() string {
}
// run runs `sww waf` with the settings in env until ctx is done,
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
// and returns its exit status. SWW_WAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int {
v := configtest.Viper(env)
if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set {
v.Set("SWW_WAF_LOOKUP_SOURCE", "off")
}
// The cache files go beside the state files unless the test says
// otherwise.
if _, set := env[cacheDir]; !set && env[stateDir] != "" {
v.Set(cacheDir, env[stateDir])
}
return sww.RunWAF(ctx, sww.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
if !ok && name == "SWW_LOOKUP_SOURCE" {
return "off", true
}
// The cache files go beside the state files unless the test
// says otherwise.
if !ok && name == cacheDir {
return env[stateDir], env[stateDir] != ""
}
return value, ok
},
Stdout: out,
Viper: v,
Stdout: out,
})
}
@@ -183,8 +182,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{}
status := run(t.Context(), map[string]string{
"SWW_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance,
"SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance,
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
@@ -195,7 +194,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
if line["type"] != "process" || line["instance"] != instance ||
line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
!strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") {
t.Errorf("start refused with %v", line)
}
}
@@ -255,7 +254,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
t.Errorf("exit status %d, want 1", status)
}
out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR")
}
func TestServesUntilToldToStop(t *testing.T) {
@@ -319,11 +318,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_LOG_LEVEL": "warn",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_WAF_LOG_LEVEL": "warn",
}, out)
}()
@@ -430,7 +429,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
t.Parallel()
const scope = "SWW_BAN_SCOPE_V4_PREFIX"
const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
env := map[string]string{
listenAddr: localhost + ":0",
@@ -557,7 +556,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
rulesDir: dir,
// The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too.
"SWW_RATE_LIMIT_PER_MINUTE": "off",
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
}
out := runUntilStopped(t, env, func(url string) {
@@ -608,7 +607,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
@@ -622,14 +621,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
want := "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory"
if line["error"] != want {
@@ -654,7 +653,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status)
@@ -673,14 +673,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE")
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_TAG_DENY": "country=KP",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_WAF_TAG_DENY": "country=KP",
// The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would
// refuse them too.
@@ -785,13 +785,13 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
torURL, dropURL := server.URL+torPath, server.URL+dropPath
cache := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
cacheDir: cache,
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_BLOCKLIST_URLS": torURL,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
cacheDir: cache,
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_WAF_BLOCKLIST_URLS": torURL,
// The requests sent until the list takes effect, and those for the
// metrics, must not break a rate limit, whose ban would refuse them
// too.
@@ -807,12 +807,12 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
})
// Restarted with drop.txt named after it and the server answering, tor.txt
// waits SWW_BLOCKLIST_REFRESH after its failed try, kept in lists.json,
// waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in lists.json,
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
failing.Store(false)
env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
env["SWW_WAF_BLOCKLIST_URLS"] = torURL + "," + dropURL
out := runUntilStopped(t, env, func(url string) {
for statusFrom(t, url, placed) != http.StatusForbidden {
time.Sleep(pollInterval)
@@ -871,12 +871,12 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
dir, cache := t.TempDir(), t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
cacheDir: cache,
rulesDir: t.TempDir(),
"SWW_CROWDSEC_LAPI_URL": engine.URL,
"SWW_CROWDSEC_LAPI_KEY": bouncerKey,
listenAddr: localhost + ":0",
stateDir: dir,
cacheDir: cache,
rulesDir: t.TempDir(),
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
}
// Once the list is fetched, the client's request bans it.
@@ -964,7 +964,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
t.Fatalf("exit status %d, want 1", status)
}
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
want := "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + path +
": no such file or directory"
line := out.line(t, "msg", "cannot use the lookup database")
@@ -990,10 +990,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
}
out := runUntilStopped(t, env, func(url string) {
@@ -1049,13 +1049,14 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWW_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
String(),
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
}
out := runUntilStopped(t, env, func(url string) {
@@ -1124,12 +1125,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
"SWW_ALERT_WEBHOOK_URL": webhook.url,
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
}
runUntilStopped(t, env, func(url string) {
@@ -1164,7 +1165,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
webhook.failing.Store(false)
env["SWW_ALLOW_NETS"] = localhost
env["SWW_WAF_ALLOW_NETS"] = localhost
env[metricsToken] = token
runUntilStopped(t, env, func(url string) {
@@ -1235,12 +1236,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
rulesDir: t.TempDir(),
rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts.
"SWW_ALLOW_NETS": localhost + "/32",
metricsToken: token,
instanceName: instance,
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWW_ALERT_NTFY_URL": ntfy.url,
"SWW_ALERT_NTFY_TOKEN": ntfyToken,
"SWW_WAF_ALLOW_NETS": localhost + "/32",
metricsToken: token,
instanceName: instance,
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWW_WAF_ALERT_NTFY_URL": ntfy.url,
"SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken,
}
runUntilStopped(t, env, func(url string) {
@@ -1314,7 +1315,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
t.Parallel()
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
"SWW_STATE_DIR cannot be written: ")
"SWW_WAF_STATE_DIR cannot be written: ")
}
func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
@@ -1335,8 +1336,11 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
line := out.line(t, "msg", "cannot use the state and cache files")
if message, _ := line["error"].(string); !strings.HasPrefix(message,
"SWW_CACHE_DIR cannot be written: ") {
t.Errorf("start refused with %q, want SWW_CACHE_DIR named", message)
"SWW_WAF_CACHE_DIR cannot be written: ") {
t.Errorf(
"start refused with %q, want SWW_WAF_CACHE_DIR named",
message,
)
}
}
@@ -1451,28 +1455,28 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
settings, _ := line["settings"].(map[string]any)
want := map[string]any{
listenAddr: localhost + ":0",
stateDir: dir,
"SWW_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
"SWW_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWW_DENY_NETS": "",
"SWW_RATE_LIMIT_PER_MINUTE": "120",
"SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h",
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_MAX_BAN_DURATION": "7d",
"SWW_ATTACK_BAN_DURATION": "7d",
"SWW_MAX_BANS": "5000",
"SWW_BAN_SCOPE_V4_PREFIX": "32",
"SWW_RULES_ENABLED": "true",
listenAddr: localhost + ":0",
stateDir: dir,
"SWW_WAF_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
"SWW_WAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWW_WAF_DENY_NETS": "",
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "120",
"SWW_WAF_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_WAF_TAG_DENY": "",
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_WAF_BAN_RESPONSE": "403",
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_WAF_MAX_BAN_DURATION": "7d",
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
"SWW_WAF_MAX_BANS": "5000",
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
"SWW_WAF_RULES_ENABLED": "true",
}
for name, value := range want {
@@ -1689,7 +1693,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
}
// wantRefused checks that waf at url refuses a request from localhost with
// 403, the default SWW_BAN_RESPONSE.
// 403, the default SWW_WAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) {
t.Helper()
@@ -1734,8 +1738,8 @@ func saveUntilAnswered(
}
}
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
// destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
// SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
// 200.
type destination struct {
url string
@@ -1794,7 +1798,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
}
}
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
// webhook is a stand-in for SWW_WAF_ALERT_WEBHOOK_URL. It notes each alert
// it is sent, and answers 204, or 503 while failing.
type webhook struct {
url string