Author SHA1 Message Date
sneak 9c755ee358 Read every setting through viper (closes #211)
check / check (push) Successful in 3m47s
servewaf's and rproxy's settings are read through viper, bound to the same environment variable names as before, 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, as it did. The _FILE forms, their errors, and rproxy reading no file form are unchanged, and the settings tests pass set into a viper instead of a lookup function.

Model: deepseek-v4-pro
2026-11-07 03:03:47 +00:00
21 changed files with 372 additions and 617 deletions
+3 -7
View File
@@ -1,5 +1,5 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
hooks build run example-app gitea-requests gitea-setup run example-app gitea-requests gitea-setup
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -7,8 +7,7 @@
# which test checks. build and run are for working on the code by hand; # which test checks. build and run are for working on the code by hand;
# example-app checks the image with an app built on it; gitea-requests # example-app checks the image with an app built on it; gitea-requests
# reports the gitea requests the Core Rule Set refuses at the defaults; # reports the gitea requests the Core Rule Set refuses at the defaults;
# gitea-setup checks the setup in deploy/gitea; publish builds the image # gitea-setup checks the setup in deploy/gitea.
# on the Docker host and pushes it to the registry.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -37,9 +36,6 @@ check:
docker: docker:
@script/docker @script/docker
publish:
@script/publish
hooks: hooks:
@script/install-precommit @script/install-precommit
-5
View File
@@ -743,11 +743,6 @@ on the host.
`make check`. `make check`.
- `script/docker`: builds the image, whose build runs the tests and the linter - `script/docker`: builds the image, whose build runs the tests and the linter
first. `make docker`. first. `make docker`.
- `script/publish`: runs `script/docker` on the Docker host `DOCKER_HOST`
(`ssh://root@fsn1app1` unless set) and pushes the image to `PUBLISH_IMAGE`
(`git.eeqj.de/sneak/smallwebwaf` unless set) under four tags: the UTC date as
`YYYY-MM-DD`, the short commit hash, the branch name and `latest`. Refuses a
working tree with uncommitted changes. `make publish`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the - `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
+10
View File
@@ -10,12 +10,14 @@ require (
github.com/maxmind/mmdbwriter v1.2.0 github.com/maxmind/mmdbwriter v1.2.0
github.com/oschwald/maxminddb-golang/v2 v2.7.0 github.com/oschwald/maxminddb-golang/v2 v2.7.0
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/spf13/viper v1.21.0
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/corazawaf/libinjection-go v0.3.3 // indirect github.com/corazawaf/libinjection-go v0.3.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
@@ -25,14 +27,22 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.17.0 // indirect github.com/magefile/mage v1.17.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
+24
View File
@@ -12,8 +12,12 @@ github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBO
github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -34,6 +38,10 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
@@ -58,8 +66,24 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+125 -314
View File
@@ -7,9 +7,6 @@
// the image runs. // the image runs.
package config package config
// FIXME: the rproxy config is in rproxy.go, the servewaf is in config.go -
// wrong and inconsistent.
import ( import (
"crypto/x509" "crypto/x509"
"errors" "errors"
@@ -29,6 +26,8 @@ import (
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
@@ -366,14 +365,11 @@ var (
"is not a header name such as accept-language") "is not a header name such as accept-language")
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " + "is taken out of every request by Go's HTTP server, so it can never " +
"be logged", "be logged")
)
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too") errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
errNotLookupSource = errors.New("is not geojs, file or off") errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up") errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New( errNeedsDBPath = errors.New("it names the file to look clients up in")
"it names the file to look clients up in",
)
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New( errOver64K = errors.New(
"is more than 64K, the most servewaf gets of a body") "is more than 64K, the most servewaf gets of a body")
@@ -400,29 +396,22 @@ var (
errNotPathPrefix = errors.New( errNotPathPrefix = errors.New(
"is not a path prefix starting with /, such as /assets/") "is not a path prefix starting with /, such as /assets/")
errNotTrapPath = errors.New( errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php", "is not a path starting with / and without a ?, such as /wp-login.php")
)
errNotWAFMode = errors.New("is not off, detect or block") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New( errNotRuleID = errors.New(
"is not the id of a Core Rule Set rule, a whole number such as 942100", "is not the id of a Core Rule Set rule, a whole number such as 942100")
)
errSetupRuleID = errors.New( errSetupRuleID = errors.New(
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
"up and of smallwebwaf's own, which cannot be switched off", "up and of smallwebwaf's own, which cannot be switched off")
)
errNotBoolean = errors.New("is not true or false") errNotBoolean = errors.New("is not true or false")
errNotLogRemoteURL = errors.New( errNotLogRemoteURL = errors.New(
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
"and nothing more, such as syslog+tls://logs.example:6514", "and nothing more, such as syslog+tls://logs.example:6514")
)
errNoCertificate = errors.New("holds no PEM certificate") errNoCertificate = errors.New("holds no PEM certificate")
errNotFacility = errors.New( errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
"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") errSetTwice = errors.New("set only one of them")
errNotWebhookURL = errors.New( errNotWebhookURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
@@ -431,135 +420,77 @@ var (
"is not a header name followed by : and the header's value, " + "is not a header name followed by : and the header's value, " +
"such as Authorization:Bearer <token>") "such as Authorization:Bearer <token>")
errControlCharacter = errors.New( errControlCharacter = errors.New(
"holds a control character, such as the carriage return of a Windows line end", "holds a control character, such as the carriage return of a Windows line end")
)
errNotAlertEvent = errors.New( errNotAlertEvent = errors.New(
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error") "source_failure or file_error")
errNotNumberOrOff = errors.New( errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
"is not a whole number above zero, such as 60, or off",
)
errNotUTF8 = errors.New("is not valid UTF-8") errNotUTF8 = errors.New("is not valid UTF-8")
errNotASN = errors.New("is not an AS number such as AS64496") errNotASN = errors.New("is not an AS number such as AS64496")
errNotPercentItem = errors.New( errNotPercentItem = errors.New(
"is not a code, : and a percentage, such as AS64496:50 or cn:25") "is not a code, : and a percentage, such as AS64496:50 or cn:25")
errNotPercent = errors.New( errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
"is not a percentage, a whole number from 0 to 100",
)
errListedTwice = errors.New("is listed twice") errListedTwice = errors.New("is listed twice")
errNotListURL = errors.New( errNotListURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
"such as https://www.spamhaus.org/drop/drop.txt") "such as https://www.spamhaus.org/drop/drop.txt")
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too") errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too")
errNotAnHourOrMore = errors.New( errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
"is not a duration of 1h or more, such as 24h",
)
errNotAction = errors.New( errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log") "is not deny, limit:<percent> such as limit:25, or log")
errNotZone = errors.New( errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
"is not a DNS zone such as dnsbl.dronebl.org", errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
) "names IPv6 clients are asked about by")
errZoneTooLong = errors.New( errNotResolver = errors.New("is not an IP address with an optional port, " +
"is longer than 189 characters, too long for the " + "such as 192.0.2.53 or [2001:db8::53]:5353")
"names IPv6 clients are asked about by",
)
errNotResolver = errors.New(
"is not an IP address with an optional port, " +
"such as 192.0.2.53 or [2001:db8::53]:5353",
)
errNotLAPIURL = errors.New( errNotLAPIURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080") "such as http://172.17.0.1:8080")
errNeedsLAPIKey = errors.New("the engine answers no request without it") errNeedsLAPIKey = errors.New("the engine answers no request without it")
errLAPIKeyUnused = errors.New( errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
"it is sent only to the engine at that URL",
)
errAnotherList = errors.New( errAnotherList = errors.New(
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too") "is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings through v, normally the viper
// os.LookupEnv. A setting may instead be given as a file: the variable // NewViper returns, bound to the process's environment. A setting may
// named by the setting's name with _FILE added names the file, which is // instead be given as a file: the variable named by the setting's name
// read now (see lookup). A setting that is not set takes its default. A // with _FILE added names the file, which is read now (see lookup). A
// setting that is set but invalid is an error that names it. // setting that is not set takes its default. A setting that is set but
// invalid is an error that names it.
// //
//nolint:funlen // one line for each setting, a list that grows with them //nolint:funlen // one line for each setting, a list that grows with them
func FromEnvironment( func FromEnvironment(v *viper.Viper) (*Config, error) {
lookupEnv func(string) (string, bool), env := &environment{v: v, readFiles: true}
) (*Config, error) {
env := &environment{lookupEnv: lookupEnv}
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR"), ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
InstanceName: env.instanceName(), InstanceName: env.instanceName(),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWWAF_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix( IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
"SWWAF_IPV6_GROUP_PREFIX", MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
"64",
),
MaxTrackedClients: env.numberNotOff(
"SWWAF_MAX_TRACKED_CLIENTS",
"20000",
),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks( RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
"SWWAF_RATE_LIMIT_EXEMPT_NETS",
"",
),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count( RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"),
"SWWAF_RATE_LIMIT_PER_MINUTE",
"120",
),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes( RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
"SWWAF_RATE_LIMIT_EXEMPT_PATHS", BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"),
"",
),
BytesLimitPerMinute: env.size(
"SWWAF_BYTES_LIMIT_PER_MINUTE",
"off",
),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource( LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
"SWWAF_LOOKUP_SOURCE",
"geojs",
),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff( LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"),
"SWWAF_LOOKUP_TIMEOUT", AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
"800ms",
),
AddLookupHeaders: env.boolean(
"SWWAF_ADD_LOOKUP_HEADERS",
"false",
),
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries( ExclusivelyAllowedCountries: env.countries(
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents( ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN),
"SWWAF_ASN_LIMIT_PERCENT", CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry),
ParseASN, ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN),
), CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry),
CountryLimitPercent: env.percents( UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"),
"SWWAF_COUNTRY_LIMIT_PERCENT",
parseCountry,
),
ASNBytesPercent: env.percents(
"SWWAF_ASN_BYTES_PERCENT",
ParseASN,
),
CountryBytesPercent: env.percents(
"SWWAF_COUNTRY_BYTES_PERCENT",
parseCountry,
),
UnknownLimitPercent: env.percent(
"SWWAF_UNKNOWN_LIMIT_PERCENT",
"100",
),
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
@@ -567,78 +498,32 @@ func FromEnvironment(
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff( AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
"SWWAF_ABUSEIPDB_DAILY_BUDGET", ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
"900", ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
), CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"),
ReputationCacheTTL: env.durationNotOff(
"SWWAF_REPUTATION_CACHE_TTL",
"24h",
),
ReputationTimeout: env.durationNotOff(
"SWWAF_REPUTATION_TIMEOUT",
"2s",
),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
"SWWAF_CROWDSEC_LAPI_URL",
),
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff( LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
"SWWAF_LIMIT_BAN_DURATION", LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
"1h", MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
), AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
LimitBanRepeatWindow: env.durationNotOff(
"SWWAF_LIMIT_BAN_REPEAT_WINDOW",
"24h",
),
MaxBanDuration: env.durationNotOff(
"SWWAF_MAX_BAN_DURATION",
"7d",
),
AttackBanDuration: env.durationNotOff(
"SWWAF_ATTACK_BAN_DURATION",
"7d",
),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix( BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
"SWWAF_BAN_SCOPE_V4_PREFIX", StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
"32", StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"),
), StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
StateDir: env.absolutePath( LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
"SWWAF_STATE_DIR", "accept,accept-language,accept-encoding,content-type,origin,range"),
"/var/lib/smallwebwaf",
),
StateWriteDelay: env.durationNotOff(
"SWWAF_STATE_WRITE_DELAY",
"10s",
),
StateCounterInterval: env.durationNotOff(
"SWWAF_STATE_COUNTER_INTERVAL",
"15m",
),
LogRequestHeaders: env.headerNames(
"SWWAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range",
),
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
AdminToken: env.token("SWWAF_ADMIN_TOKEN"), AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"), MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
RulesDir: env.value( RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
"SWWAF_RULES_DIR",
"/etc/smallwebwaf/rules.d",
),
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel( WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
"SWWAF_WAF_PARANOIA_LEVEL", WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"),
"1",
),
WAFAnomalyThreshold: env.numberOrOff(
"SWWAF_WAF_ANOMALY_THRESHOLD",
"5",
),
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"), "920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
@@ -646,46 +531,25 @@ func FromEnvironment(
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates( LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
"SWWAF_LOG_REMOTE_TLS_CA_FILE", LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
), LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
LogRemoteBuffer: env.numberNotOff(
"SWWAF_LOG_REMOTE_BUFFER",
"10000",
),
LogRemoteFacility: env.facility(
"SWWAF_LOG_REMOTE_FACILITY",
"local0",
),
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
AlertWebhookHeaders: env.webhookHeaders( AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"),
"SWWAF_ALERT_WEBHOOK_HEADERS", AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"),
),
AlertSlackWebhookURL: env.webhookURL(
"SWWAF_ALERT_SLACK_WEBHOOK_URL",
),
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
strings.Join(alerts.Events(), ",")), strings.Join(alerts.Events(), ",")),
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff( AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
"SWWAF_ALERT_MAX_PER_HOUR",
"60",
),
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWWAF_WATCH_"), AnomalyWatch: env.thresholds("SWWAF_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix( AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"),
"SWWAF_ANOMALY_NET_V4_PREFIX", AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"),
"24",
),
AnomalyNetV6Prefix: env.v6Prefix(
"SWWAF_ANOMALY_NET_V6_PREFIX",
"48",
),
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"),
} }
@@ -714,8 +578,8 @@ func FromEnvironment(
// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its // Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its
// health check checks: rproxy when it is unset or empty, or servewaf. It cannot // health check checks: rproxy when it is unset or empty, or servewaf. It cannot
// be given as a file. Any other value is an error that names the setting. // be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) { func Command(v *viper.Viper) (string, error) {
command, _ := lookupEnv("SMALLWEBWAF_COMMAND") command, _ := readSetting(v, "SMALLWEBWAF_COMMAND")
switch command { switch command {
case "", "rproxy": case "", "rproxy":
@@ -723,21 +587,15 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
case "servewaf": case "servewaf":
return command, nil return command, nil
default: default:
return "", fmt.Errorf( return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand)
"SMALLWEBWAF_COMMAND: %q %w",
command,
errNotCommand,
)
} }
} }
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a // ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in servewaf's container needs // file, as FromEnvironment does. The health check in servewaf's container needs
// no other setting, so it reads no other, nor a file that another names. // no other setting, so it reads no other, nor a file that another names.
func ServeWAFListenAddr( func ServeWAFListenAddr(v *viper.Viper) (string, error) {
lookupEnv func(string) (string, bool), env := &environment{v: v, readFiles: true}
) (string, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR") listenAddr := env.address("SWWAF_LISTEN_ADDR")
return listenAddr, env.err return listenAddr, env.err
@@ -747,8 +605,8 @@ func ServeWAFListenAddr(
// file, as FromEnvironment does, so that the line saying a setting is // file, as FromEnvironment does, so that the line saying a setting is
// invalid carries it too. A file that cannot be read gives the default // invalid carries it too. A file that cannot be read gives the default
// here, and FromEnvironment then stops the start over it. // here, and FromEnvironment then stops the start over it.
func InstanceName(lookupEnv func(string) (string, bool)) string { func InstanceName(v *viper.Viper) string {
env := &environment{lookupEnv: lookupEnv} env := &environment{v: v, readFiles: true}
return env.instanceName() return env.instanceName()
} }
@@ -764,13 +622,26 @@ func (c *Config) LogValue() slog.Value {
} }
// environment is where FromEnvironment reads the settings: it notes each // environment is where FromEnvironment reads the settings: it notes each
// value for the log, and keeps the first error. // value for the log, and keeps the first error. It reads every setting
// through v, the viper NewViper returns, and, unless readFiles is false,
// honours each setting's _FILE form too.
type environment struct { type environment struct {
lookupEnv func(string) (string, bool) v *viper.Viper
readFiles bool
settings []slog.Attr settings []slog.Attr
err error err error
} }
// readSetting returns a setting's value and whether it is set, reading it
// through v.
func readSetting(v *viper.Viper, name string) (string, bool) {
if !v.IsSet(name) {
return "", false
}
return v.GetString(name), true
}
// 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 {
@@ -789,28 +660,27 @@ func (e *environment) value(name, defaultValue string) string {
// names, less one newline at their end. It notes that file's path for the // 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. // log. Both variables set, or a file that cannot be read, is an error.
func (e *environment) lookup(name string) (string, bool) { func (e *environment) lookup(name string) (string, bool) {
value, set := e.lookupEnv(name) value, set := readSetting(e.v, name)
if !e.readFiles {
return value, set
}
fileName := name + "_FILE" fileName := name + "_FILE"
path, inFile := e.lookupEnv(fileName) path, inFile := readSetting(e.v, fileName)
if !inFile { if !inFile {
return value, set return value, set
} }
if set { if set {
e.check( e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
name,
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
)
return value, set return value, set
} }
e.settings = append(e.settings, slog.String(fileName, path)) e.settings = append(e.settings, slog.String(fileName, path))
contents, err := os.ReadFile( contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names
path,
) //nolint:gosec // a file the admin names
if err != nil { if err != nil {
e.check(fileName, fmt.Errorf("cannot be read: %w", err)) e.check(fileName, fmt.Errorf("cannot be read: %w", err))
@@ -1061,10 +931,7 @@ func (e *environment) zones(name string) []string {
logged[i] = MaskZoneKey(zone) logged[i] = MaskZoneKey(zone)
} }
e.settings = append( e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
e.settings,
slog.String(name, strings.Join(logged, ",")),
)
return zones return zones
} }
@@ -1116,15 +983,10 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
switch { switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf( e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWWAF_LOOKUP_DB_PATH is unset; %w", "is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
errNeedsDBPath,
))
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWWAF_LOOKUP_SOURCE is %s; %w", "is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
cfg.LookupSource,
errDBPathUnused,
))
} }
} }
@@ -1165,11 +1027,8 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, {"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
} { } {
if setting.set { if setting.set {
e.check( e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
setting.name, errNeedsLookups))
fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
errNeedsLookups),
)
} }
} }
} }
@@ -1192,21 +1051,14 @@ func (e *environment) checkCrowdSec(cfg *Config) {
switch { switch {
case decisionsURL != "" && cfg.CrowdSecKey == "": case decisionsURL != "" && cfg.CrowdSecKey == "":
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf( e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", "is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
errNeedsLAPIKey,
))
case decisionsURL == "" && cfg.CrowdSecKey != "": case decisionsURL == "" && cfg.CrowdSecKey != "":
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf( e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", "is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
errLAPIKeyUnused,
))
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
decisionsURL == cfg.ASNLimitPercentURL): decisionsURL == cfg.ASNLimitPercentURL):
e.check( e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
"SWWAF_CROWDSEC_LAPI_URL", decisionsURL, errAnotherList))
fmt.Errorf("gives the decision list %q, which %w",
decisionsURL, errAnotherList),
)
} }
} }
@@ -1221,9 +1073,7 @@ func (e *environment) headerNames(name, defaultValue string) []string {
// durationNotOff reads a setting that is a duration and, unlike a // durationNotOff reads a setting that is a duration and, unlike a
// timeout, cannot be off. // timeout, cannot be off.
func (e *environment) durationNotOff( func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
name, defaultValue string,
) time.Duration {
duration, err := ParseDurationNotOff(e.value(name, defaultValue)) duration, err := ParseDurationNotOff(e.value(name, defaultValue))
e.check(name, err) e.check(name, err)
@@ -1363,7 +1213,7 @@ func (e *environment) logRemoteURL(name string) *url.URL {
// certificates. Unset or empty, it is nil. Its value names a file // certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form. // 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.lookupEnv(name) path, _ := readSetting(e.v, name)
e.settings = append(e.settings, slog.String(name, path)) e.settings = append(e.settings, slog.String(name, path))
if path == "" { if path == "" {
@@ -1403,10 +1253,7 @@ func (e *environment) instanceName() string {
value := e.value("SWWAF_INSTANCE_NAME", hostname) value := e.value("SWWAF_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) { if !utf8.ValidString(value) {
e.check( e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
"SWWAF_INSTANCE_NAME",
fmt.Errorf("%q %w", value, errNotUTF8),
)
} }
return value return value
@@ -1415,10 +1262,7 @@ func (e *environment) instanceName() string {
// appName reads the setting that is the APP-NAME of the records the log // appName reads the setting that is the APP-NAME of the records the log
// 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( func (e *environment) appName(name, instanceName string, sending bool) string {
name, instanceName string,
sending bool,
) string {
value, set := e.lookup(name) value, set := e.lookup(name)
if !set { if !set {
value = instanceName value = instanceName
@@ -1431,14 +1275,8 @@ func (e *environment) appName(
case set: case set:
e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
case sending: case sending:
e.check( e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
name, value, errNotAppName))
fmt.Errorf(
"is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
value,
errNotAppName,
),
)
} }
return value return value
@@ -1447,16 +1285,11 @@ func (e *environment) appName(
// checkInstanceNameForNtfy refuses an instance name that holds a control // checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent // character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one. // the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy( func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
instanceName string,
ntfySet bool,
) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf( e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", "%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set",
instanceName, instanceName, errControlCharacter))
errControlCharacter,
))
} }
} }
@@ -1776,10 +1609,7 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
return nil, fmt.Errorf("%q %w", name, errListedTwice) return nil, fmt.Errorf("%q %w", name, errListedTwice)
} }
named = append( named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
named,
anomaly.NamedNetblock{Name: name, Netblock: netblock},
)
} }
return named, nil return named, nil
@@ -2018,11 +1848,8 @@ func parseHeaderNames(value string) ([]string, error) {
header := strings.ToLower(item) header := strings.ToLower(item)
switch header { switch header {
case "host": case "host":
return nil, fmt.Errorf( return nil, fmt.Errorf("%q %w; the request's host is the field host",
"%q %w; the request's host is the field host", item, errHeaderTakenOut)
item,
errHeaderTakenOut,
)
case "transfer-encoding": case "transfer-encoding":
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
} }
@@ -2059,11 +1886,9 @@ func parseUpstreamURL(value string) (*url.URL, error) {
} }
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
upstream.Hostname() != "" && upstream.User == nil && upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") && (upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" && upstream.RawQuery == "" && upstream.Fragment == ""
upstream.Fragment == ""
if !onlySchemeAndHost { if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
} }
@@ -2087,11 +1912,7 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
} }
schemes := []string{ schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
remotelog.SchemeUDP,
remotelog.SchemeTCP,
remotelog.SchemeTLS,
}
port, err := strconv.ParseUint(remote.Port(), 10, 16) port, err := strconv.ParseUint(remote.Port(), 10, 16)
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
@@ -2154,9 +1975,7 @@ func isHTTPURL(u *url.URL) bool {
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
(u.Port() == "" || (err == nil && port != 0)) && (u.Port() == "" || (err == nil && port != 0)) &&
u.User == nil && u.User == nil && u.Opaque == "" && u.Fragment == ""
u.Opaque == "" &&
u.Fragment == ""
} }
// isListURL reports whether value is a URL a list can be fetched from, as // isListURL reports whether value is a URL a list can be fetched from, as
@@ -2223,12 +2042,9 @@ func parseZones(value string) ([]string, error) {
for i, zone := range zones { for i, zone := range zones {
shown := MaskZoneKey(zone) shown := MaskZoneKey(zone)
listedBefore := slices.ContainsFunc( listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
zones[:i],
func(earlier string) bool {
return strings.EqualFold(MaskZoneKey(earlier), shown) return strings.EqualFold(MaskZoneKey(earlier), shown)
}, })
)
switch { switch {
case len(zone) > maxZoneLength: case len(zone) > maxZoneLength:
@@ -2315,13 +2131,8 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
name, headerValue, found := strings.Cut(item, ":") name, headerValue, found := strings.Cut(item, ":")
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if !found || !IsHeaderName(name) || if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
strings.ContainsAny(headerValue, "\r\n\x00") { return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
return nil, "", fmt.Errorf(
"item %d %w",
i+1,
errNotWebhookHeader,
)
} }
headers.Add(name, strings.TrimSpace(headerValue)) headers.Add(name, strings.TrimSpace(headerValue))
+41 -35
View File
@@ -17,6 +17,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
) )
@@ -217,18 +219,22 @@ const (
// environment is a set of environment variables, for FromEnvironment. // environment is a set of environment variables, for FromEnvironment.
type environment map[string]string type environment map[string]string
// lookupEnv reads one of the variables, as os.LookupEnv does. // viper returns a viper Set with each of the variables, as the viper
func (e environment) lookupEnv(name string) (string, bool) { // NewViper returns one bound to a process's environment.
value, ok := e[name] func (e environment) viper() *viper.Viper {
v := viper.New()
for name, value := range e {
v.Set(name, value)
}
return value, ok return v
} }
// fromEnvironment reads the settings from env, which must be valid. // fromEnvironment reads the settings from env, which must be valid.
func fromEnvironment(t *testing.T, env environment) *config.Config { func fromEnvironment(t *testing.T, env environment) *config.Config {
t.Helper() t.Helper()
cfg, err := config.FromEnvironment(env.lookupEnv) cfg, err := config.FromEnvironment(env.viper())
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", env, err) t.Fatalf("settings %v: %v", env, err)
} }
@@ -455,7 +461,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment( _, err := config.FromEnvironment(
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv) environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` + want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
`starting with /, such as /assets/` `starting with /, such as /assets/`
@@ -510,7 +516,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -636,7 +642,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -761,7 +767,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
{logRemoteAppName, ""}, {logRemoteAppName, "my app"}, {logRemoteAppName, ""}, {logRemoteAppName, "my app"},
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)}, {logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
} { } {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err) t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
} }
@@ -778,7 +784,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err) t.Fatalf("write %s: %v", caFile, err)
} }
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv) _, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate` want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -793,7 +799,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
sending := environment{logRemoteURL: remoteURL, instanceName: spaced} sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
_, err := config.FromEnvironment(sending.lookupEnv) _, err := config.FromEnvironment(sending.viper())
want := logRemoteAppName + `: is unset, and ` + instanceName + want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
@@ -823,7 +829,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
logRemoteURL: remoteURL, logRemoteURL: remoteURL,
instanceName: instance, instanceName: instance,
logRemoteAppName: "my app", logRemoteAppName: "my app",
}.lookupEnv) }.viper())
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as gitea`
@@ -954,7 +960,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
// An item that is not a header is named by its place, not shown. // An item that is not a header is named by its place, not shown.
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
alertWebhookHeaders: "X-Team:ops," + secret, alertWebhookHeaders: "X-Team:ops," + secret,
}.lookupEnv) }.viper())
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " + want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
"and the header's value, such as Authorization:Bearer <token>" "and the header's value, such as Authorization:Bearer <token>"
@@ -991,7 +997,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
"ftp://hooks.example/services/" + secret, "ftp://hooks.example/services/" + secret,
"https://hooks.example/services/%zz" + secret, "https://hooks.example/services/%zz" + secret,
} { } {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) _, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
want := alertWebhookURL + ": is not an http or https URL without a user or " + want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/smallwebwaf" "a fragment, such as https://alerts.example/smallwebwaf"
@@ -1042,7 +1048,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
"set": {alertNtfyToken: token + "\r"}, "set": {alertNtfyToken: token + "\r"},
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")}, "in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
} { } {
_, err := config.FromEnvironment(env.lookupEnv) _, err := config.FromEnvironment(env.viper())
want := alertNtfyToken + ": holds a control character, such as the " + want := alertNtfyToken + ": holds a control character, such as the " +
"carriage return of a Windows line end" "carriage return of a Windows line end"
@@ -1059,7 +1065,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
}.lookupEnv) }.viper())
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
`carriage return of a Windows line end, and is sent to ntfy in a header ` + `carriage return of a Windows line end, and is sent to ntfy in a header ` +
@@ -1084,7 +1090,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
"set": {instanceName: latin1}, "set": {instanceName: latin1},
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")}, "in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
} { } {
_, err := config.FromEnvironment(env.lookupEnv) _, err := config.FromEnvironment(env.viper())
want := instanceName + `: "caf\xe9" is not valid UTF-8` want := instanceName + `: "caf\xe9" is not valid UTF-8`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1099,7 +1105,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru", deniedCountries: "cn,ru",
allowedCountries: "de,RU", allowedCountries: "de,RU",
}.lookupEnv) }.viper())
if err == nil { if err == nil {
t.Fatal("ru on both country lists was accepted") t.Fatal("ru on both country lists was accepted")
} }
@@ -1161,7 +1167,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it", lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
}, },
} { } {
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want { if err == nil || err.Error() != tc.want {
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want) t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
} }
@@ -1191,7 +1197,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
lookupSource: off, name: value, lookupSource: off, name: value,
}.lookupEnv) }.viper())
if err == nil || !strings.HasPrefix(err.Error(), name+": ") || if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
!strings.Contains(err.Error(), lookupSource+" is off") { !strings.Contains(err.Error(), lookupSource+" is off") {
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource) t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
@@ -1341,7 +1347,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1420,7 +1426,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1517,7 +1523,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1532,7 +1538,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv) }.viper())
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too` want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1659,7 +1665,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1727,7 +1733,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`}, {spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
{spamhaus + ",,", "has an empty item in its list"}, {spamhaus + ",,", "has an empty item in its list"},
} { } {
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
want := dnsblZones + ": " + tc.want want := dnsblZones + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1780,7 +1786,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want want := tc.name + ": " + tc.want
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -1894,7 +1900,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv) _, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want { if err == nil || err.Error() != tc.want {
t.Errorf("error %v, want %s", err, tc.want) t.Errorf("error %v, want %s", err, tc.want)
} }
@@ -2054,7 +2060,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv) _, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil { if err == nil {
t.Fatalf("%s=%q was accepted", tc.name, tc.value) t.Fatalf("%s=%q was accepted", tc.name, tc.value)
} }
@@ -2079,7 +2085,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv) _, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
if err == nil || err.Error() != logRequestHeaders+": "+want { if err == nil || err.Error() != logRequestHeaders+": "+want {
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want) t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
} }
@@ -2096,7 +2102,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
t.Run(name+"="+value, func(t *testing.T) { t.Run(name+"="+value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{name: value}.lookupEnv) _, err := config.FromEnvironment(environment{name: value}.viper())
want := name + ": is shorter than 32 characters" want := name + ": is shorter than 32 characters"
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2152,7 +2158,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"), bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv) }.viper())
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off` want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
@@ -2163,7 +2169,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
logRemoteURL: remoteURL, logRemoteURL: remoteURL,
instanceName: instance, instanceName: instance,
logRemoteAppName + "_FILE": writeFile(t, "my app\n"), logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
}.lookupEnv) }.viper())
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as gitea`
@@ -2178,7 +2184,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
metricsToken: token, metricsToken: token,
metricsToken + "_FILE": writeFile(t, token), metricsToken + "_FILE": writeFile(t, token),
}.lookupEnv) }.viper())
want := metricsToken + ": is set, and so is " + metricsToken + want := metricsToken + ": is set, and so is " + metricsToken +
"_FILE; set only one of them" "_FILE; set only one of them"
@@ -2193,7 +2199,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} { for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv) _, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
want := metricsToken + "_FILE: cannot be read: " want := metricsToken + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) { if err == nil || !strings.HasPrefix(err.Error(), want) {
+11 -24
View File
@@ -8,8 +8,9 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/spf13/viper"
) )
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero // RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
@@ -58,13 +59,13 @@ var (
"is not a whole number of milliseconds above zero, such as 60000, or off") "is not a whole number of milliseconds above zero, such as 60000, or off")
) )
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally // RProxyFromEnvironment reads rproxy's settings through v, normally the viper
// os.LookupEnv. A setting that is not set takes its default, and // NewViper returns, bound to the process's environment. A setting that is not
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but // set takes its default, and SWRP_WAF_BACKEND, which has none, must be set. A
// invalid is an error that names it. Since rproxy never touches disk, its // setting that is set but invalid is an error that names it. Since rproxy
// settings have no _FILE form. // never touches disk, its settings have no _FILE form.
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)} env := &environment{v: v, readFiles: false}
cfg := &RProxy{ cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR"), ListenAddr: env.address("SWRP_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
@@ -91,9 +92,9 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other // SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other. // setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL( func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool), v *viper.Viper,
) (string, *url.URL, error) { ) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)} env := &environment{v: v, readFiles: false}
listenAddr := env.address("SWRP_LISTEN_ADDR") listenAddr := env.address("SWRP_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL) upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
@@ -104,20 +105,6 @@ func RProxyListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil return listenAddr, upstreamURL, nil
} }
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
// rproxy never touches disk.
func withoutFileForms(
lookupEnv func(string) (string, bool),
) func(string) (string, bool) {
return func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}
}
// LogValue makes an RProxy log as each setting's name with the value it was // LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default. // given, or its default.
func (c *RProxy) LogValue() slog.Value { func (c *RProxy) LogValue() slog.Value {
+7 -4
View File
@@ -9,6 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
) )
@@ -32,11 +34,12 @@ const (
// rproxyConfig reads rproxy's settings from env. // rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) { func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) { v := viper.New()
value, ok := env[name] for name, value := range env {
v.Set(name, value)
}
return value, ok return config.RProxyFromEnvironment(v)
})
} }
func TestRProxyDefaults(t *testing.T) { func TestRProxyDefaults(t *testing.T) {
+16
View File
@@ -0,0 +1,16 @@
package config
import "github.com/spf13/viper"
// NewViper returns the viper that reads the settings: each setting's
// environment variable, bound to its own name. Every setting of servewaf and
// rproxy is read through it, and none is read with os.Getenv or os.LookupEnv
// outside it. An environment variable that is set but empty is set, as one
// that holds a value is, which viper does not take for granted.
func NewViper() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
v.AllowEmptyEnv(true)
return v
}
+6 -28
View File
@@ -16,11 +16,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
) )
// FIXME: you can't call things just "File" - that's super vague.
// FIXME: "lookup database" - in memory on disk? you must not be so
// ambiguous when describing things.
// quietTime is how long the lookup database must go without a change // quietTime is how long the lookup database must go without a change
// before it is read again, so that a file still being copied in is read // before it is read again, so that a file still being copied in is read
// only once whole. // only once whole.
@@ -138,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
} }
if err != nil { if err != nil {
f.params.ProcessLog.Error( f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
"cannot watch the lookup database for replacements", "error", err.Error())
"error",
err.Error(),
)
return return
} }
@@ -202,10 +194,7 @@ func (f *File) readAgain() {
f.params.Alerts.Raise(alerts.Alert{ f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: kept, Reason: kept,
Detail: map[string]any{ Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
"file": f.params.Path,
"error": err.Error(),
},
}) })
f.params.ProcessLog.Error(kept, "error", err.Error()) f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -223,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
f.lastRead = f.params.Now() f.lastRead = f.params.Now()
f.mu.Unlock() f.mu.Unlock()
f.params.ProcessLog.Info( f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
"read the lookup database",
"file",
f.params.Path,
)
} }
// read reads the lookup database at path. The whole file is read into // read reads the lookup database at path. The whole file is read into
@@ -236,19 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) { func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
"SWWAF_LOOKUP_DB_PATH cannot be read: %w",
err,
)
} }
reader, err := maxminddb.OpenBytes(data) reader, err := maxminddb.OpenBytes(data)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
"SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w",
path,
err,
)
} }
return reader, nil return reader, nil
+4 -38
View File
@@ -19,51 +19,29 @@ const (
// ActionForward is a request servewaf allowed, which rproxy passes to the // ActionForward is a request servewaf allowed, which rproxy passes to the
// app. // app.
ActionForward = "forward" ActionForward = "forward"
//FIXME: "too_large" doesn't tell a reader what that means.
//request_header_too_large does. you must not use ambiguous terms.
// ActionTooLarge is a request over the header limits. // ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client. // over a rate limit, which bans the client.
//FIXME: better would be "client_is_rate_limited"
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
// ActionBanned is a request refused because a ban covers its client, // ActionBanned is a request refused because a ban covers its client,
// or because it matched a ban rule, asked for a trap path or the // or because it matched a ban rule, asked for a trap path or the
// CrowdSec decision list lists its client, each of which bans the // CrowdSec decision list lists its client, each of which bans the
// client. // client.
// FIXME: if I saw this, i would assume they are permanently banned. is that
// true? if not, there should be TemporarilyBanned and
// PermanentlyBanned
ActionBanned = "banned" ActionBanned = "banned"
// ActionRuleBlocked is a request refused because it matched a block // ActionRuleBlocked is a request refused because it matched a block
// rule. // rule.
// FIXME: "blocked_by_block_rule"
ActionRuleBlocked = "rule_blocked" ActionRuleBlocked = "rule_blocked"
// ActionWAFBlocked is a request refused because the Core Rule Set // ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. // scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked" ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWWAF_REPUTATION_ACTION is deny. // SWWAF_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// FIXME: you have country_denied and denied. why isn't it
// client_denied? ALWAYS BE CONSISTENT AND UNAMBIGUOUS
// ActionAdmin is a request servewaf answered at one of its own endpoints, // ActionAdmin is a request servewaf answered at one of its own endpoints,
// or a question about a path under /_smallwebwaf/. // or a question about a path under /_smallwebwaf/.
ActionAdmin = "admin" ActionAdmin = "admin"
@@ -267,11 +245,7 @@ func FormatTime(t time.Time) string {
// Milliseconds is d in milliseconds, to the microsecond. // Milliseconds is d in milliseconds, to the microsecond.
func Milliseconds(d time.Duration) float64 { func Milliseconds(d time.Duration) float64 {
return float64( return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
d.Microseconds(),
) / float64(
time.Millisecond/time.Microsecond,
)
} }
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
@@ -279,25 +253,17 @@ func Milliseconds(d time.Duration) float64 {
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; // It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger( func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
w io.Writer,
instanceName string,
level slog.Level,
) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level, Level: level,
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey && len(groups) == 0 { if attr.Key == slog.TimeKey && len(groups) == 0 {
return slog.String( return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
slog.TimeKey,
FormatTime(attr.Value.Time()),
)
} }
return attr return attr
}, },
}) })
return slog.New(handler). return slog.New(handler).With("type", "process", "instance", instanceName)
With("type", "process", "instance", instanceName)
} }
+3 -7
View File
@@ -33,13 +33,9 @@ func announcedLength(size int) string {
func TestServerHasTheFixedLimits(t *testing.T) { func TestServerHasTheFixedLimits(t *testing.T) {
t.Parallel() t.Parallel()
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { cfg, err := config.RProxyFromEnvironment(viperFrom(map[string]string{
if name == "SWRP_WAF_BACKEND" { "SWRP_WAF_BACKEND": nowhere,
return nowhere, true }))
}
return "", false
})
if err != nil { if err != nil {
t.Fatalf("settings: %v", err) t.Fatalf("settings: %v", err)
} }
+14 -5
View File
@@ -18,6 +18,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
@@ -341,6 +343,17 @@ func startServer(
return addr, out, server return addr, out, server
} }
// viperFrom returns a viper Set with each of settings, as the viper
// config.NewViper returns one bound to a process's environment.
func viperFrom(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
// startServerLogging is startServer with rproxy's request lines going to // startServerLogging is startServer with rproxy's request lines going to
// requestLog and its process lines to processLog. // requestLog and its process lines to processLog.
func startServerLogging( func startServerLogging(
@@ -354,11 +367,7 @@ func startServerLogging(
} }
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { cfg, err := config.RProxyFromEnvironment(viperFrom(settings))
value, ok := settings[name]
return value, ok
})
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
+15 -10
View File
@@ -15,6 +15,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -347,6 +349,17 @@ func serve(t *testing.T, server *servewaf.Server) {
// at serveWAFAddr, with the settings in env on top of the defaults, and returns // 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 // 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. // env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
// viperFrom returns a viper Set with each of settings, as the viper
// config.NewViper returns one bound to a process's environment.
func viperFrom(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
func startRProxy( func startRProxy(
t *testing.T, serveWAFAddr, appURL string, env map[string]string, t *testing.T, serveWAFAddr, appURL string, env map[string]string,
) string { ) string {
@@ -359,11 +372,7 @@ func startRProxy(
} }
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { cfg, err := config.RProxyFromEnvironment(viperFrom(settings))
value, ok := settings[name]
return value, ok
})
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
@@ -423,11 +432,7 @@ func newServeWAFWith(
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.FromEnvironment(func(name string) (string, bool) { cfg, err := config.FromEnvironment(viperFrom(settings))
value, ok := settings[name]
return value, ok
})
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
+10 -8
View File
@@ -10,6 +10,8 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
@@ -31,7 +33,7 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// 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(
ctx context.Context, args []string, lookupEnv func(string) (string, bool), ctx context.Context, args []string, v *viper.Viper,
stderr io.Writer, stderr io.Writer,
) int { ) int {
if len(args) > 0 { if len(args) > 0 {
@@ -44,15 +46,15 @@ func HealthCheck(
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel() defer cancel()
command, err := config.Command(lookupEnv) command, err := config.Command(v)
switch { switch {
case err != nil: case err != nil:
err = fmt.Errorf("invalid setting: %w", err) err = fmt.Errorf("invalid setting: %w", err)
case command == "servewaf": case command == "servewaf":
err = checkServeWAF(ctx, lookupEnv) err = checkServeWAF(ctx, v)
default: default:
err = checkRProxyAndApp(ctx, lookupEnv) err = checkRProxyAndApp(ctx, v)
} }
if err != nil { if err != nil {
@@ -65,8 +67,8 @@ func HealthCheck(
} }
// checkServeWAF asks servewaf's health endpoint. // checkServeWAF asks servewaf's health endpoint.
func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error { func checkServeWAF(ctx context.Context, v *viper.Viper) error {
listenAddr, err := config.ServeWAFListenAddr(lookupEnv) listenAddr, err := config.ServeWAFListenAddr(v)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
@@ -81,9 +83,9 @@ func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) e
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app. // checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
func checkRProxyAndApp( func checkRProxyAndApp(
ctx context.Context, lookupEnv func(string) (string, bool), ctx context.Context, v *viper.Viper,
) error { ) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv) listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
+2 -10
View File
@@ -145,11 +145,7 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
noSettings := func(string) (string, bool) { got := smallwebwaf.HealthCheck(t.Context(), []string{"now"}, viperFrom(nil), &stderr)
return "", false
}
got := smallwebwaf.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
want := "smallwebwaf healthcheck: unexpected argument \"now\"\n" want := "smallwebwaf healthcheck: unexpected argument \"now\"\n"
if got != 1 || stderr.String() != want { if got != 1 || stderr.String() != want {
@@ -166,11 +162,7 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
var stderr bytes.Buffer var stderr bytes.Buffer
got := smallwebwaf.HealthCheck(t.Context(), nil, func(name string) (string, bool) { got := smallwebwaf.HealthCheck(t.Context(), nil, viperFrom(env), &stderr)
value, ok := env[name]
return value, ok
}, &stderr)
wrote := stderr.String() wrote := stderr.String()
if got != status || !strings.HasPrefix(wrote, message) || if got != status || !strings.HasPrefix(wrote, message) ||
+1 -1
View File
@@ -23,7 +23,7 @@ func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo) processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
cfg, err := config.RProxyFromEnvironment(params.LookupEnv) cfg, err := config.RProxyFromEnvironment(params.Viper)
if err != nil { if err != nil {
processLog.Error("invalid setting", "error", err.Error()) processLog.Error("invalid setting", "error", err.Error())
+1 -5
View File
@@ -43,11 +43,7 @@ const (
func runRProxy(ctx context.Context, env map[string]string, out *output) int { func runRProxy(ctx context.Context, env map[string]string, out *output) int {
return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{ return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{
Version: testVersion, Version: testVersion,
LookupEnv: func(name string) (string, bool) { Viper: viperFrom(env),
value, ok := env[name]
return value, ok
},
Stdout: out, Stdout: out,
}) })
} }
+9 -6
View File
@@ -20,6 +20,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -58,8 +60,9 @@ commands:
type Params struct { type Params struct {
// Version is the version of the binary, set when it is built. // Version is the version of the binary, set when it is built.
Version string Version string
// LookupEnv reads an environment variable, normally os.LookupEnv. // Viper reads the settings, normally the viper config.NewViper returns
LookupEnv func(string) (string, bool) // for the process's environment.
Viper *viper.Viper
// Stdout receives the request log and the process's own messages. // Stdout receives the request log and the process's own messages.
Stdout io.Writer Stdout io.Writer
} }
@@ -78,7 +81,7 @@ func Main(version string) int {
syscall.SIGTERM, os.Interrupt) syscall.SIGTERM, os.Interrupt)
defer stop() defer stop()
params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout} params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout}
switch args[0] { switch args[0] {
case "rproxy": case "rproxy":
@@ -86,7 +89,7 @@ func Main(version string) int {
case "servewaf": case "servewaf":
return RunServeWAF(ctx, params) return RunServeWAF(ctx, params)
case "healthcheck": case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr) return HealthCheck(ctx, args[1:], params.Viper, os.Stderr)
default: default:
return printUsage(os.Stderr) return printUsage(os.Stderr)
} }
@@ -106,9 +109,9 @@ func RunServeWAF(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's // Until the settings are read, the one message is an invalid setting's
// error, which every SWWAF_LOG_LEVEL lets through. // error, which every SWWAF_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout, processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.LookupEnv), slog.LevelError) config.InstanceName(params.Viper), slog.LevelError)
cfg, err := config.FromEnvironment(params.LookupEnv) cfg, err := config.FromEnvironment(params.Viper)
if err != nil { if err != nil {
processLog.Error("invalid setting", "error", err.Error()) processLog.Error("invalid setting", "error", err.Error())
+19 -8
View File
@@ -19,6 +19,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/servewaf" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
@@ -122,20 +124,29 @@ func (o *output) text() string {
return o.buf.String() return o.buf.String()
} }
// viperFrom returns a viper Set with each of settings, as the viper
// config.NewViper returns one bound to a process's environment.
func viperFrom(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
// run runs `smallwebwaf servewaf` with the settings in env until ctx is done, // run runs `smallwebwaf servewaf` with the settings in env until ctx is done,
// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it, // and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses. // so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int { func run(ctx context.Context, env map[string]string, out *output) int {
return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{ v := viperFrom(env)
Version: testVersion, if _, set := env["SWWAF_LOOKUP_SOURCE"]; !set {
LookupEnv: func(name string) (string, bool) { v.Set("SWWAF_LOOKUP_SOURCE", "off")
value, ok := env[name]
if !ok && name == "SWWAF_LOOKUP_SOURCE" {
return "off", true
} }
return value, ok return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{
}, Version: testVersion,
Viper: v,
Stdout: out, Stdout: out,
}) })
} }
-51
View File
@@ -1,51 +0,0 @@
#!/bin/sh
# script/publish: build the image on the Docker host and push it to the
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
# hash, the branch name and latest. The build is script/docker's, so the
# tests and the linter run first. The host and the registry name are
# environment variables with defaults, for running it against another.
#
# The working tree must be clean: the hash tag names a commit, and an
# image built from uncommitted changes would carry a hash that does not
# describe it.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
export DOCKER_HOST
main() {
cd "$ROOT"
if [ -n "$(git status --porcelain)" ]; then
echo "script/publish: the working tree is not clean; commit or stash first" >&2
exit 1
fi
date="$(date -u +%Y-%m-%d)"
hash="$(git rev-parse --short HEAD)"
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" = "HEAD" ]; then
echo "script/publish: detached HEAD; check out a branch first" >&2
exit 1
fi
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
local_tag="$("$SCRIPT_DIR/projectname")"
"$SCRIPT_DIR/docker"
for tag in "$date" "$hash" "$branch" latest; do
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
done
for tag in "$date" "$hash" "$branch" latest; do
docker push "$PUBLISH_IMAGE:$tag"
done
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
}
main "$@"