Settings given as files: the _FILE form of every setting (closes #87)
check / check (push) Successful in 4m53s

Every setting X may instead be given as a file that X_FILE names, read
once at start: its contents, less one trailing newline, are the value,
checked as X would be. X and X_FILE both set, or a file that cannot be
read, stops the start with a message naming the variable. The logged
settings name the file, and mask a token read from one.
SWWAF_LOG_REMOTE_TLS_CA_FILE, whose value is a file already, has no
_FILE form. The health check reads only SWWAF_LISTEN_ADDR and
SWWAF_UPSTREAM_URL, so no other setting or file can fail it.

Judgement call: an invalid value read from a file is named as X, not X_FILE.
Rule suppressed: gosec G304 on reading the named file, as for the CA file.

Model: opus-5-5
This commit was merged in pull request #89.
This commit is contained in:
2026-10-07 00:01:19 +02:00
parent ee9ba08a8a
commit bff65f4e2f
6 changed files with 283 additions and 24 deletions
+38 -4
View File
@@ -181,9 +181,10 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
## Settings ## Settings
Each setting is an environment variable, and each has a default, so none has to Each setting is an environment variable, or a file one names (see "Settings
be set. A setting that is set but invalid stops the start with a message naming given as files" below), and each has a default, so none has to be set. A setting
it, and the effective settings are logged at start. that is set but invalid stops the start with a message naming it, and the
effective settings are logged at start.
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens. - `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens.
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or - `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or
@@ -288,7 +289,8 @@ it, and the effective settings are logged at start.
- `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for the - `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for the
metrics, a long random value. While it is unset the metrics are off; one metrics, a long random value. While it is unset the metrics are off; one
shorter than 32 characters stops the start. The settings logged at start show shorter than 32 characters stops the start. The settings logged at start show
`********` in its place. `********` in its place. Given as a file, it can be kept out of the app's
reach (see "Settings given as files" below).
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their - `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
own in the metrics by country; the others are counted as `other`. own in the metrics by country; the others are counted as `other`.
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the - `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
@@ -329,6 +331,38 @@ with their counters and history, and an IPv6 client is counted by its /64. A new
client waits at most a second for its country, and at most 100,000 answers from client waits at most a second for its country, and at most 100,000 answers from
GeoJS are kept, for 7 days each. GeoJS are kept, for 7 days each.
### Settings given as files
Any setting may instead be given as a file that holds its value: the variable
named like the setting with `_FILE` added, such as `SWWAF_METRICS_TOKEN_FILE`,
names the file. `smallwebwaf` reads the file once, at start, and its health
check reads only the files of `SWWAF_LISTEN_ADDR` and `SWWAF_UPSTREAM_URL`, each
time it runs. The file's contents are the value, less one newline at their end
so that a file written with `echo` or an editor works, and are checked as the
setting's own value would be. Setting both the setting and its `_FILE` form, or
naming a file that cannot be read, stops the start with a message naming the
variable. The settings logged at start name the file, and show a token given in
one as `********`, as they show one given directly.
`SWWAF_LOG_REMOTE_TLS_CA_FILE`, whose value names a file already, has no `_FILE`
form.
The app starts with the same environment variables as `smallwebwaf`, so it can
read a token given as one. A token given as a file is out of the app's reach
only while the `smallwebwaf` user alone can read the file: make it on the host,
owned by uid 65532, the `smallwebwaf` user, with mode `0400`, and mount the
directory that holds it into the container read-only; the container sees the
same owner and mode. For example, on the host:
```sh
mkdir -p /srv/app/tokens
openssl rand -hex 32 > /srv/app/tokens/metrics
chown 65532:65532 /srv/app/tokens/metrics
chmod 0400 /srv/app/tokens/metrics
```
and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
`-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics`.
## Request log ## Request log
`smallwebwaf` writes one JSON object per line on stdout for every request, `smallwebwaf` writes one JSON object per line on stdout for every request,
+3 -2
View File
@@ -32,7 +32,7 @@ from a directory of hand-editable text files.
- Defence against traffic floods that saturate the host's network link. That - Defence against traffic floods that saturate the host's network link. That
needs help upstream of the host. needs help upstream of the host.
- A web UI or a configuration file. Settings are environment variables. Apart - A web UI or a configuration file. Settings are environment variables. Apart
from settings given as files (the `_FILE` form of any setting, such as from settings given as files (the `_FILE` form of a setting, such as
`SWWAF_ADMIN_TOKEN_FILE`, and `SWWAF_LOG_REMOTE_TLS_CA_FILE`), its own state `SWWAF_ADMIN_TOKEN_FILE`, and `SWWAF_LOG_REMOTE_TLS_CA_FILE`), its own state
files and the lookup database, the only files read are the rule files, which files and the lookup database, the only files read are the rule files, which
hold one regex per line and nothing more elaborate. hold one regex per line and nothing more elaborate.
@@ -298,7 +298,8 @@ it.
- A list set to an empty value is an empty list, and replaces the default. - A list set to an empty value is an empty list, and replaces the default.
- Every setting may instead be given as a file holding the value, named by the - Every setting may instead be given as a file holding the value, named by the
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
secrets and long lists. secrets and long lists. `SWWAF_LOG_REMOTE_TLS_CA_FILE`, whose value names a
file already, has no `_FILE` form.
- Settings, including those given as files, are read once at start; changing one - Settings, including those given as files, are read once at start; changing one
means restarting the container. The files `smallwebwaf` watches while it runs means restarting the container. The files `smallwebwaf` watches while it runs
are its state files, its rule files and the lookup database. are its state files, its rule files and the lookup database.
+78 -15
View File
@@ -1,6 +1,7 @@
// Package config reads smallwebwaf's settings. Every setting is an // Package config reads smallwebwaf's settings. Every setting is an
// environment variable whose name starts with SWWAF_, every setting has a // environment variable whose name starts with SWWAF_, or a file such a
// default, and this package is the one place they are read. // variable names, every setting has a default, and this package is the
// one place they are read.
package config package config
import ( import (
@@ -154,8 +155,8 @@ type Config struct {
LogRemoteFacility int LogRemoteFacility int
LogRemoteAppName string LogRemoteAppName string
// settings are the values read, as given or by default, for the // settings are the values read, as given or by default, and the
// log line at start. // files they were read from, for the log line at start.
settings []slog.Attr settings []slog.Attr
} }
@@ -173,6 +174,10 @@ const (
minTokenLength = 32 minTokenLength = 32
// masked is what the log shows for a token that is set. // masked is what the log shows for a token that is set.
masked = "********" masked = "********"
// defaultListenAddr and defaultUpstreamURL are the defaults of
// SWWAF_LISTEN_ADDR and SWWAF_UPSTREAM_URL.
defaultListenAddr = ":8080"
defaultUpstreamURL = "http://127.0.0.1:8081"
) )
var ( var (
@@ -221,17 +226,20 @@ var (
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
errNotAppName = errors.New( errNotAppName = errors.New(
"is not 1 to 48 printable ASCII characters without a space, such as gitea") "is not 1 to 48 printable ASCII characters without a space, such as gitea")
errSetTwice = errors.New("set only one of them")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
// os.LookupEnv. A setting that is not set takes its default. A setting // os.LookupEnv. A setting may instead be given as a file: the variable
// that is set but invalid is an error that names it. // named by the setting's name with _FILE added names the file, which is
// read now (see lookup). A setting that is not set takes its default. A
// setting that is set but invalid is an error that names it.
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR", ":8080"), ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr),
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"), UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
InstanceName: env.value("SWWAF_INSTANCE_NAME", hostname), InstanceName: env.value("SWWAF_INSTANCE_NAME", hostname),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWWAF_MODE", "enforce"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
@@ -295,6 +303,24 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
return cfg, nil return cfg, nil
} }
// ListenAddrAndUpstreamURL reads only SWWAF_LISTEN_ADDR and
// SWWAF_UPSTREAM_URL, either of which may be given as a file, as
// FromEnvironment does. The health check needs no other setting, so it
// reads no other, nor a file that another names.
func ListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool),
) (string, *url.URL, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR", defaultListenAddr)
upstreamURL := env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil {
return "", nil, env.err
}
return listenAddr, upstreamURL, nil
}
// privateRanges are the private address ranges, the default trusted // privateRanges are the private address ranges, the default trusted
// proxies. // proxies.
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
@@ -316,8 +342,8 @@ type environment struct {
// value returns a setting's value, or its default when it is not set, // value returns a setting's value, or its default when it is not set,
// and notes it for the log. // and notes it for the log.
func (e *environment) value(name, defaultValue string) string { func (e *environment) value(name, defaultValue string) string {
value, ok := e.lookupEnv(name) value, set := e.lookup(name)
if !ok { if !set {
value = defaultValue value = defaultValue
} }
@@ -326,6 +352,37 @@ func (e *environment) value(name, defaultValue string) string {
return value return value
} }
// lookup returns a setting's value and whether it is set: the value of the
// variable name, or the contents of the file that the variable name_FILE
// names, less one newline at their end. It notes that file's path for the
// log. Both variables set, or a file that cannot be read, is an error.
func (e *environment) lookup(name string) (string, bool) {
value, set := e.lookupEnv(name)
fileName := name + "_FILE"
path, inFile := e.lookupEnv(fileName)
if !inFile {
return value, set
}
if set {
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
return value, set
}
e.settings = append(e.settings, slog.String(fileName, path))
contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names
if err != nil {
e.check(fileName, fmt.Errorf("cannot be read: %w", err))
return "", false
}
return strings.TrimSuffix(string(contents), "\n"), true
}
// check keeps the first error, naming the setting it is about. // check keeps the first error, naming the setting it is about.
func (e *environment) check(name string, err error) { func (e *environment) check(name string, err error) {
if err != nil && e.err == nil { if err != nil && e.err == nil {
@@ -484,7 +541,7 @@ func (e *environment) absolutePath(name, defaultValue string) string {
// switches off what it guards; set, it must be at least minTokenLength // switches off what it guards; set, it must be at least minTokenLength
// characters. Neither the log nor an error shows its value. // characters. Neither the log nor an error shows its value.
func (e *environment) token(name string) string { func (e *environment) token(name string) string {
value, set := e.lookupEnv(name) value, set := e.lookup(name)
if !set { if !set {
e.settings = append(e.settings, slog.String(name, "")) e.settings = append(e.settings, slog.String(name, ""))
@@ -515,9 +572,12 @@ func (e *environment) logRemoteURL(name string) *url.URL {
} }
// certificates reads a setting that is the path of a file of PEM // certificates reads a setting that is the path of a file of PEM
// certificates. Unset or empty, it is nil. // certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form.
func (e *environment) certificates(name string) *x509.CertPool { func (e *environment) certificates(name string) *x509.CertPool {
path := e.value(name, "") path, _ := e.lookupEnv(name)
e.settings = append(e.settings, slog.String(name, path))
if path == "" { if path == "" {
return nil return nil
} }
@@ -552,9 +612,12 @@ func (e *environment) facility(name, defaultValue string) int {
// lines are sent in, by default the instance name. Its value is checked // lines are sent in, by default the instance name. Its value is checked
// when it is set, and, while lines are sent, when it is the instance name. // when it is set, and, while lines are sent, when it is the instance name.
func (e *environment) appName(name, instanceName string, sending bool) string { func (e *environment) appName(name, instanceName string, sending bool) string {
_, set := e.lookupEnv(name) value, set := e.lookup(name)
if !set {
value = instanceName
}
value := e.value(name, instanceName) e.settings = append(e.settings, slog.String(name, value))
switch { switch {
case isAppName(value): case isAppName(value):
+127
View File
@@ -722,6 +722,133 @@ func TestTokenIsLoggedMasked(t *testing.T) {
} }
} }
func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) {
t.Parallel()
for contents, want := range map[string]string{
token: token,
token + "\n": token,
token + "\n\n": token + "\n",
token + " \n": token + " ",
} {
cfg := fromEnvironment(t, environment{
metricsToken + "_FILE": writeFile(t, contents),
})
if cfg.MetricsToken != want {
t.Errorf("file holding %q gave %s %q, want %q", contents, metricsToken,
cfg.MetricsToken, want)
}
}
}
func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{
requestMaxBytes + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv)
want := requestMaxBytes + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
_, err = config.FromEnvironment(environment{
logRemoteURL: remoteURL,
instanceName: instance,
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
}.lookupEnv)
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{
metricsToken: token,
metricsToken + "_FILE": writeFile(t, token),
}.lookupEnv)
want := metricsToken + ": is set, and so is " + metricsToken +
"_FILE; set only one of them"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
want := metricsToken + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
}
}
func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
t.Parallel()
path := writeFile(t, token+"\n")
cfg := fromEnvironment(t, environment{metricsToken + "_FILE": path})
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
var line struct {
Settings map[string]string `json:"settings"`
}
err := json.Unmarshal(out.Bytes(), &line)
if err != nil {
t.Fatalf("decode %s: %v", out.Bytes(), err)
}
if strings.Contains(out.String(), token) ||
line.Settings[metricsToken] != "********" ||
line.Settings[metricsToken+"_FILE"] != path {
t.Errorf("the token is not logged masked, with its file %s: %s", path,
out.String())
}
}
func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{
logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"),
})
if cfg.LogRemoteTLSCAs != nil {
t.Errorf("%s_FILE gave certificates", logRemoteTLSCAFile)
}
}
// writeFile writes contents to a file in a directory of its own, removed
// when the test ends, and returns the file's path.
func writeFile(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "setting")
err := os.WriteFile(path, []byte(contents), 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func TestLogsEachSettingWithItsValue(t *testing.T) { func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel() t.Parallel()
+5 -3
View File
@@ -23,6 +23,8 @@ var errHealthEndpoint = errors.New("smallwebwaf's health endpoint answered")
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in // smallwebwaf answers its health endpoint on 127.0.0.1, at the port in
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in // SWWAF_LISTEN_ADDR, and the app accepts connections at the address in
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. // SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1.
// It reads no other setting, nor a file that another names, so neither
// can fail it.
// args are the arguments after `healthcheck`; it takes none, and given // args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything. // one it names it on stderr and returns 1 without checking anything.
func HealthCheck( func HealthCheck(
@@ -50,13 +52,13 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel() defer cancel()
cfg, err := config.FromEnvironment(lookupEnv) listenAddr, upstreamURL, err := config.ListenAddrAndUpstreamURL(lookupEnv)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
// The settings have checked that the address has a port. // The settings have checked that the address has a port.
_, port, _ := net.SplitHostPort(cfg.ListenAddr) _, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody) req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
@@ -75,7 +77,7 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status) return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
} }
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(cfg.UpstreamURL)) conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
if err != nil { if err != nil {
return fmt.Errorf("connect to the app: %w", err) return fmt.Errorf("connect to the app: %w", err)
} }
+32
View File
@@ -6,6 +6,8 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -43,6 +45,21 @@ func TestHealthCheck(t *testing.T) {
wantHealthCheck(t, env, 0, "") wantHealthCheck(t, env, 0, "")
// The health check reads those two settings alone, here given as
// files: a removed or invalid token file, or an invalid value of
// another setting, does not fail it.
for _, other := range []struct{ name, value string }{
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWWAF_MODE", "neither"},
} {
wantHealthCheck(t, map[string]string{
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
upstreamURL + "_FILE": writeFile(t, app.URL+"\n"),
other.name: other.value,
}, 0, "")
}
app.Close() app.Close()
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ") wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
@@ -98,3 +115,18 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
got, wrote, status, message) got, wrote, status, message)
} }
} }
// writeFile writes contents to a file in a directory of its own, removed
// when the test ends, and returns the file's path.
func writeFile(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "setting")
err := os.WriteFile(path, []byte(contents), 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}