1 Commits
Author SHA1 Message Date
clawbot e3b159db07 rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 8m17s
`smallwebwaf` with no command now prints the commands and exits with status 2.
The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The
passing-on code, its settings and its log fields and metric go. internal/proxy
keeps the checks, answers, reports and own endpoints; its tests run through the
real rproxy. The documents describe the pair.

Judgement call: clients.json keeps token_refused, since state files refuse unknown fields.
Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests.

Model: opus-5-5
2026-11-05 08:54:11 +00:00
6 changed files with 77 additions and 27 deletions

No files matched your search

+8 -6
View File
@@ -16,7 +16,8 @@ commands that do this together:
counters and history. One `servewaf` serves many sites. counters and history. One `servewaf` serves many sites.
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy` The site keeps answering while `servewaf` is down, slow or restarting. `rproxy`
waits 2 seconds for an answer, and then passes the request to the app. waits at most 2 seconds for `servewaf`'s answer, and without one passes the
request to the app.
`rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf` `rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf`
is. In a container of its own, it also needs `SWRP_UPSTREAM_URL`, which says is. In a container of its own, it also needs `SWRP_UPSTREAM_URL`, which says
@@ -214,9 +215,9 @@ gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part.
## What happens to a request ## What happens to a request
A request goes through fifteen steps, in this order. Once `servewaf` refuses a A request goes through fifteen steps, in this order. Once `servewaf` refuses a
request, the steps after that are skipped, apart from step 15, and nothing of request, the steps after that are skipped, apart from the report of step 15, and
the request reaches the app. "What happens to a request" in [`SPEC.md`](SPEC.md) nothing of the request reaches the app. "What happens to a request" in
gives each step in full, under the same numbers. [`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
`rproxy` takes the first two: `rproxy` takes the first two:
@@ -242,8 +243,9 @@ gives each step in full, under the same numbers.
`rproxy` takes the rest, with `servewaf` counting at the end: `rproxy` takes the rest, with `servewaf` counting at the end:
14. The request is passed to the app. 14. The request is passed to the app.
15. After the answer, `rproxy` sends `servewaf` a report. `servewaf` counts the 15. After the answer, `rproxy` sends `servewaf` a report. For an allowed
byte limits and adds the request to the client's history. request, `servewaf` counts the byte limits and adds the request to the
client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a "Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
+9 -6
View File
@@ -1579,8 +1579,8 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
`SMALLWEBWAF_COMMAND` names, `rproxy` when it is unset or empty, as the `SMALLWEBWAF_COMMAND` names, `rproxy` when it is unset or empty, as the
`smallwebwaf` user (uid and gid 65532) with runit's `chpst`. For `servewaf`, `smallwebwaf` user (uid and gid 65532) with runit's `chpst`. For `servewaf`,
it first makes `SWWAF_STATE_DIR` and every file in it belong to that user. For it first makes `SWWAF_STATE_DIR` and every file in it belong to that user. For
`rproxy` it changes no owner. With any other value, `smallwebwaf` prints its `rproxy` it changes no owner. With any other value, it starts nothing, and
commands and exits, and the health check fails. stops with a message naming `SMALLWEBWAF_COMMAND` and its value.
Every `run` script, the app's included, is a bash script. It starts with Every `run` script, the app's included, is a bash script. It starts with
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main` `#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
@@ -1629,10 +1629,13 @@ says what it checks:
- With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers - With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in `GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in
`SWWAF_LISTEN_ADDR`. `SWWAF_LISTEN_ADDR`.
- Otherwise it passes while `rproxy` answers that request on `127.0.0.1`, at the - With `SMALLWEBWAF_COMMAND` unset, empty or `rproxy`, it passes while `rproxy`
port in `SWRP_LISTEN_ADDR`, and the app accepts connections at answers that request on `127.0.0.1`, at the port in `SWRP_LISTEN_ADDR`, and
`SWRP_UPSTREAM_URL`. It does not ask `servewaf`, so that the site stays in the app accepts connections at `SWRP_UPSTREAM_URL`. It does not ask
traefik's routes while `servewaf` is down. `servewaf`, so that the site stays in traefik's routes while `servewaf` is
down.
- With any other value, it fails with a message naming `SMALLWEBWAF_COMMAND` and
its value.
traefik sends a container no requests until it shows as healthy. So the check traefik sends a container no requests until it shows as healthy. So the check
runs every second from the container's start until it first passes, for up to a runs every second from the container's start until it first passes, for up to a
+19 -1
View File
@@ -3,7 +3,8 @@
// servewaf` start with SWWAF_, every one of them has a default, and most can be // servewaf` start with SWWAF_, every one of them has a default, and most can be
// given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each // given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file, // of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. // since rproxy never touches disk. SMALLWEBWAF_COMMAND chooses which of the two
// the image runs.
package config package config
import ( import (
@@ -370,6 +371,7 @@ var (
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")
errNotCommand = errors.New("is not rproxy or servewaf")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -570,6 +572,22 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
return cfg, nil return cfg, nil
} }
// 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
// be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) {
command, _ := lookupEnv("SMALLWEBWAF_COMMAND")
switch command {
case "", "rproxy":
return "rproxy", nil
case "servewaf":
return command, nil
default:
return "", fmt.Errorf("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.
+16 -9
View File
@@ -21,11 +21,13 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In servewaf's container, where // HealthCheck is the container's health check. In servewaf's container, where
// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health // SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In any other container // endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container,
// it returns 0 while rproxy answers its health endpoint on 127.0.0.1, at the port // where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// in SWRP_LISTEN_ADDR, and the app accepts connections at the address in // answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// SWRP_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. It reads no // the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
// other setting, nor a file that another names, so neither can fail it. // any other value of SMALLWEBWAF_COMMAND included, it writes why to stderr and
// returns 1. It reads no other setting, nor a file that another names, so neither
// can fail it.
// args are the arguments after `healthcheck`; it takes none, and given // args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything. // one it names it on stderr and returns 1 without checking anything.
func HealthCheck( func HealthCheck(
@@ -42,12 +44,17 @@ func HealthCheck(
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel() defer cancel()
check := checkRProxyAndApp command, err := config.Command(lookupEnv)
if command, _ := lookupEnv("SMALLWEBWAF_COMMAND"); command == "servewaf" {
check = checkServeWAF switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "servewaf":
err = checkServeWAF(ctx, lookupEnv)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
} }
err := check(ctx, lookupEnv)
if err != nil { if err != nil {
_, _ = fmt.Fprintln(stderr, "unhealthy:", err) _, _ = fmt.Fprintln(stderr, "unhealthy:", err)
+13
View File
@@ -103,8 +103,13 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
} }
wantHealthCheck(t, settings(), 0, "") wantHealthCheck(t, settings(), 0, "")
wantHealthCheck(t, settings(commandSetting, ""), 0, "")
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "") wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
// SMALLWEBWAF_COMMAND cannot be given as a file.
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")),
0, "")
// Another setting does not fail it, even an invalid one, and neither does // Another setting does not fail it, even an invalid one, and neither does
// SWRP_WAF_BACKEND being unset. // SWRP_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "") wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
@@ -127,6 +132,14 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
}, 1, "unhealthy: ask rproxy: ") }, 1, "unhealthy: ask rproxy: ")
} }
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
t.Parallel()
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
"unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+
"\"healthcheck\" is not rproxy or servewaf\n")
}
func TestHealthCheckRefusesAnArgument(t *testing.T) { func TestHealthCheckRefusesAnArgument(t *testing.T) {
t.Parallel() t.Parallel()
+12 -5
View File
@@ -3,14 +3,21 @@ set -euo pipefail
# runit's run script for smallwebwaf, run again whenever smallwebwaf # runit's run script for smallwebwaf, run again whenever smallwebwaf
# exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the # exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the
# command to run: rproxy when it is unset, or servewaf. exec, so that the # command to run: rproxy when it is unset or empty, or servewaf. With any
# signal `sv stop` sends reaches smallwebwaf itself. # other value the script starts nothing and stops with a message naming it.
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
main() { main() {
sleep 1 sleep 1
local command="${SMALLWEBWAF_COMMAND:-rproxy}" local command="${SMALLWEBWAF_COMMAND:-rproxy}"
if [[ "$command" == servewaf ]]; then case "$command" in
give_state_dir_to_smallwebwaf rproxy) ;;
fi servewaf) give_state_dir_to_smallwebwaf ;;
*)
printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \
"$command" >&2
exit 1
;;
esac
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command" exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command"
} }