1 Commits
Author SHA1 Message Date
clawbot 4b0af7f85b rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Successful in 17m6s
`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 06:26:21 +00:00
8 changed files with 83 additions and 102 deletions
+25 -4
View File
@@ -133,11 +133,32 @@ RUN mkdir /etc/sysusers.d \
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
> /etc/sysusers.d/runit.conf
# ca-certificates, nix-bin and runit, from Ubuntu's normal mirrors: the
# live archive the image's own sources name, over plain HTTP, so no CA
# certificate is needed and none is borrowed from the Go image.
RUN apt-get update \
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at
# the snapshot moment, which is never earlier than the Ubuntu image above.
# apt checks every package against the snapshot's InRelease files, and
# this step checks those against the hashes named here, which are those
# of the amd64 archive: other architectures use Ubuntu's ports archive.
# apt also fetches the live archive's InRelease files, which change daily
# and which the install does not use. The snapshot service is HTTPS only
# and this image has no CA certificates yet, so this step uses the Go
# image's. The snapshot service answers 503 at times; apt retries a
# failed download, waiting twice as long before each retry, and this
# step gives it five retries, about half a minute in all, where its
# default of three gives up after seven seconds.
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
apt-get update --snapshot 20261001T000000Z \
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
-o Acquire::Retries=5 \
&& printf '%s\n' \
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
'64b3353f0bd4970b4f7271962245bcea9ff24d4cc7bea16b433f8a60e42ca3dd snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-backports_InRelease' \
'1d5041572116a8b23aabf79ac7439ad8af83d57ad3fb0f9aa0d4523ec10c5908 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-security_InRelease' \
| (cd /var/lib/apt/lists && sha256sum --check --strict) \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
--snapshot 20261001T000000Z \
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
-o Acquire::Retries=5 \
ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/*
+6 -8
View File
@@ -16,8 +16,7 @@ commands that do this together:
counters and history. One `servewaf` serves many sites.
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy`
waits at most 2 seconds for `servewaf`'s answer, and without one passes the
request to the app.
waits 2 seconds for an answer, and then passes the request to the app.
`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
@@ -215,9 +214,9 @@ gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part.
## What happens to a request
A request goes through fifteen steps, in this order. Once `servewaf` refuses a
request, the steps after that are skipped, apart from the report of step 15, and
nothing of the request reaches the app. "What happens to a request" in
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
request, the steps after that are skipped, apart from step 15, and nothing of
the request reaches the app. "What happens to a request" in [`SPEC.md`](SPEC.md)
gives each step in full, under the same numbers.
`rproxy` takes the first two:
@@ -243,9 +242,8 @@ nothing of the request reaches the app. "What happens to a request" in
`rproxy` takes the rest, with `servewaf` counting at the end:
14. The request is passed to the app.
15. After the answer, `rproxy` sends `servewaf` a report. For an allowed
request, `servewaf` counts the byte limits and adds the request to the
client's history.
15. After the answer, `rproxy` sends `servewaf` a report. `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
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
+31 -26
View File
@@ -1544,12 +1544,20 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
- The image is Ubuntu's newest long-term support release, pinned by digest, and
moves to the next LTS release when that ships. Its packages come from Ubuntu's
normal mirrors, the live archive the image's own sources name, whose packages
change with every update.
snapshot service, the archive as it was at the moment the Dockerfile names,
rather than the live archive, whose packages change with every update. The
snapshot is never older than the pinned Ubuntu image, since older packages can
need older versions of packages the image holds, and it moves forward with the
image's digest. The Dockerfile names the hash of each of the snapshot's
`InRelease` files, which cover every package list and so every package, and
the build checks them before installing. The live archive's `InRelease` files,
which `apt-get update` also fetches and the install does not use, are left out
of the check. The hashes are the amd64 archive's, so the image is built for
amd64 only.
- The Ubuntu image has no CA certificates. Without them Nix cannot download
packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed
by name. The archive is reached over plain HTTP, so the install needs no CA
certificate from the builder.
by name. The snapshot itself is fetched over HTTPS with the CA certificates of
the Go image that builds `smallwebwaf`, itself pinned by digest.
- nixpkgs comes from a release file on `releases.nixos.org`, named with its
hash, not from a GitHub archive, whose bytes can change. Root uses Nix
directly; no Nix daemon runs. `nix-bin` creates no build users, so
@@ -1567,13 +1575,12 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
writes. `runsvinit` starts runit's `runsvdir`, which runs the `run` script of
each directory under `/etc/service`. Ubuntu's `sv` looks for services there
too.
- The image's `run` script is `/etc/service/smallwebwaf/run`. It starts
`servewaf` when `SMALLWEBWAF_COMMAND` is `servewaf`, and `rproxy` when it is
`rproxy`, unset or empty. It runs either as the `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 `rproxy` it
changes no owner. With any other value, it starts nothing, and stops with a
message naming `SMALLWEBWAF_COMMAND` and its value.
- The image's `run` script, `/etc/service/smallwebwaf/run`, starts the command
`SMALLWEBWAF_COMMAND` names, `rproxy` when it is unset or empty, as the
`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
`rproxy` it changes no owner. With any other value, `smallwebwaf` prints its
commands and exits, and the health check fails.
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`
@@ -1622,13 +1629,10 @@ says what it checks:
- With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in
`SWWAF_LISTEN_ADDR`.
- With `SMALLWEBWAF_COMMAND` unset, empty or `rproxy`, it passes while `rproxy`
answers that request on `127.0.0.1`, at the port in `SWRP_LISTEN_ADDR`, and
the app accepts connections at `SWRP_UPSTREAM_URL`. It does not ask
`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.
- Otherwise it passes while `rproxy` answers that request on `127.0.0.1`, at the
port in `SWRP_LISTEN_ADDR`, and the app accepts connections at
`SWRP_UPSTREAM_URL`. It does not ask `servewaf`, so that the site stays in
traefik's routes while `servewaf` is down.
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
@@ -1693,14 +1697,15 @@ ranges traefik is on.
### upaas
upaas rebuilds and restarts an app's Docker container when its repository
changes. An app it deploys takes the shape of "`rproxy` in the app's container"
with no change to upaas. upaas builds the app's Dockerfile and runs the one
container as it runs any app. The traefik labels route to port 8080, and the
`SWRP_` settings go with the app's environment variables. `servewaf` runs in a
container of its own, on a network the app's container can reach, with its state
volume and the directory of any token file. `docs/gitea.md` records what the
defaults do in front of gitea, and `script/gitea-requests` checks those notes.
An app deployed by upaas, which rebuilds and restarts an app's Docker container
when its repository changes, takes the shape of "`rproxy` in the app's
container" with no change to upaas. upaas builds the app's Dockerfile and runs
the one container as it runs any app. The traefik labels route to port 8080, and
the `SWRP_` settings go with the app's environment variables. `servewaf` runs in
a container of its own, on a network the app's container can reach, with its
state volume and the directory of any token file. `docs/gitea.md` records what
the defaults do in front of gitea, and `script/gitea-requests` checks those
notes.
## Planned, not built
+6 -4
View File
@@ -8,8 +8,8 @@ is pinned that way.
## Contents
- Ubuntu 26.04 LTS, pinned by digest.
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu's normal mirrors, the
live archive, not a dated snapshot.
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, from the snapshot of its
archive at `20261001T000000Z`.
- Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as
`nixpkgs`. No Nix daemon; `build-users-group =` in `/etc/nix/nix.conf`. Root's
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
@@ -26,8 +26,10 @@ is pinned that way.
## Pins
- Ubuntu's packages are not pinned: they come from the live archive, whose
packages change with every update.
- Ubuntu's packages: the snapshot service, `--snapshot 20261001T000000Z` on both
`apt-get update` and `apt-get install`. The SHA-256 hash of each of the
snapshot's `InRelease` files is named in the `Dockerfile` and checked after
`apt-get update`. The hashes are the amd64 archive's.
- nixpkgs: the release file `nixexprs.tar.xz` of
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
SHA-256 hash.
+1 -19
View File
@@ -3,8 +3,7 @@
// 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
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. SMALLWEBWAF_COMMAND chooses which of the two
// the image runs.
// since rproxy never touches disk.
package config
import (
@@ -371,7 +370,6 @@ var (
errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New(
"is more than 64K, the most servewaf gets of a body")
errNotCommand = errors.New("is not rproxy or servewaf")
errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New(
@@ -572,22 +570,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
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
// 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.
+9 -16
View File
@@ -21,13 +21,11 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In servewaf's container, where
// 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 rproxy's container,
// where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
// 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.
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In any other container
// it returns 0 while rproxy answers its health endpoint on 127.0.0.1, at the port
// in SWRP_LISTEN_ADDR, and the app accepts connections at the address in
// SWRP_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. It reads no
// other setting, nor a file that another names, so neither can fail it.
// args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything.
func HealthCheck(
@@ -44,17 +42,12 @@ func HealthCheck(
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
command, err := config.Command(lookupEnv)
switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "servewaf":
err = checkServeWAF(ctx, lookupEnv)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
check := checkRProxyAndApp
if command, _ := lookupEnv("SMALLWEBWAF_COMMAND"); command == "servewaf" {
check = checkServeWAF
}
err := check(ctx, lookupEnv)
if err != nil {
_, _ = fmt.Fprintln(stderr, "unhealthy:", err)
-13
View File
@@ -103,13 +103,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
}
wantHealthCheck(t, settings(), 0, "")
wantHealthCheck(t, settings(commandSetting, ""), 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
// SWRP_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
@@ -132,14 +127,6 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
}, 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) {
t.Parallel()
+5 -12
View File
@@ -3,21 +3,14 @@ set -euo pipefail
# runit's run script for smallwebwaf, run again whenever smallwebwaf
# exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the
# command to run: rproxy when it is unset or empty, or servewaf. With any
# other value the script starts nothing and stops with a message naming it.
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
# command to run: rproxy when it is unset, or servewaf. exec, so that the
# signal `sv stop` sends reaches smallwebwaf itself.
main() {
sleep 1
local command="${SMALLWEBWAF_COMMAND:-rproxy}"
case "$command" in
rproxy) ;;
servewaf) give_state_dir_to_smallwebwaf ;;
*)
printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \
"$command" >&2
exit 1
;;
esac
if [[ "$command" == servewaf ]]; then
give_state_dir_to_smallwebwaf
fi
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command"
}