Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m1s

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 is contained in:
2026-10-06 22:57:12 +00:00
parent bff65f4e2f
commit 5bf7802404
15 changed files with 1373 additions and 104 deletions
+9 -4
View File
@@ -129,6 +129,10 @@ type Config struct {
// LogRequestHeaders are the request headers whose values the request
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
LogRequestHeaders []string
// AdminToken is the bearer token an admin sends for the ban endpoints
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
// unset and they are off.
AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
// MetricsTopN is how many countries get series of their own in the
@@ -274,6 +278,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range"),
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
@@ -496,7 +501,7 @@ func (e *environment) headerNames(name, defaultValue string) []string {
// durationNotOff reads a setting that is a duration and, unlike a
// timeout, cannot be off.
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
duration, err := parseDurationNotOff(e.value(name, defaultValue))
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
e.check(name, err)
return duration
@@ -732,9 +737,9 @@ func parseCount(value string) (int64, error) {
return n, nil
}
// parseDurationNotOff reads a duration above zero, as parseDuration does,
// but not off.
func parseDurationNotOff(value string) (time.Duration, error) {
// ParseDurationNotOff reads a duration above zero, as parseDuration does,
// but not off. The ban endpoint reads the duration of a ban with it too.
func ParseDurationNotOff(value string) (time.Duration, error) {
duration, err := parseDuration(value)
if err != nil || duration == 0 {
return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero)
+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,