Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Waiting to run

SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST
/_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET
/_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong
token gets 401, in observe mode too. They go through every check, as
the metrics do. POST takes a netblock, not IPv4-mapped and without a
zone, or a client's address, a duration or permanent, and a reason, and
makes an admin ban even while another lasts. DELETE lifts every active
ban covering the address, kept and marked lifted. Bans come back as
bans.json entries; a client as clients.json holds it, with its bans.

Judgement call: answers leave out bans.json's version field.
Judgement call: DELETE takes an address, not a netblock.
Rule suppressed: gosec G304 on a test reading bans.json.

Model: opus-5-5
This commit was merged in pull request #92.
This commit is contained in:
2026-10-07 01:13:16 +02:00
parent bff65f4e2f
commit 5d6f6ffaf9
15 changed files with 1373 additions and 104 deletions
+30 -16
View File
@@ -50,6 +50,7 @@ const (
stateDir = "SWWAF_STATE_DIR"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N"
instanceName = "SWWAF_INSTANCE_NAME"
@@ -81,8 +82,12 @@ rlG9y/jrJb6ORy3kTLWo2EA0BA67vuI=
-----END CERTIFICATE-----
`
// token is a token of 32 characters, the shortest allowed.
const token = "0123456789abcdef0123456789abcdef"
// token is a token of 32 characters, the shortest allowed, and
// otherToken another.
const (
token = "0123456789abcdef0123456789abcdef"
otherToken = "fedcba9876543210fedcba9876543210"
)
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
@@ -693,32 +698,40 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
t.Parallel()
// Characters are counted, not bytes: each é takes two.
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
t.Run(value, func(t *testing.T) {
t.Parallel()
for _, name := range []string{adminToken, metricsToken} {
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
t.Run(name+"="+value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{metricsToken: value}.lookupEnv)
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
want := metricsToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
want := name + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
}
func TestTokenIsLoggedMasked(t *testing.T) {
func TestTokensAreReadAndLoggedMasked(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{metricsToken: token})
cfg := fromEnvironment(t, environment{adminToken: otherToken, metricsToken: token})
if cfg.AdminToken != otherToken || cfg.MetricsToken != token {
t.Errorf("admin token %q and metrics token %q, want %q and %q",
cfg.AdminToken, cfg.MetricsToken, otherToken, token)
}
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
if strings.Contains(out.String(), token) ||
!strings.Contains(out.String(), `"`+metricsToken+`":"********"`) {
t.Errorf("the token is not logged masked: %s", out.String())
logged := out.String()
if strings.Contains(logged, token) || strings.Contains(logged, otherToken) ||
!strings.Contains(logged, `"`+adminToken+`":"********"`) ||
!strings.Contains(logged, `"`+metricsToken+`":"********"`) {
t.Errorf("the tokens are not logged masked: %s", logged)
}
}
@@ -901,6 +914,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
stateDir: "/var/lib/smallwebwaf",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
adminToken: "",
metricsToken: "",
metricsTopN: "50",
instanceName: hostname,