The image apps build FROM, with its health check (closes #45)
check / check (push) Failing after 2s

The Dockerfile's last stage is now the image of "Deployment" in SPEC.md:
Ubuntu 26.04 with ca-certificates, nix-bin and runit from a dated
snapshot whose InRelease files are checked by hash, nixpkgs from its
release file checked by SHA-256, runsvinit built at a fixed commit, and
smallwebwaf as a runit service. smallwebwaf answers
/_smallwebwaf/healthz, and `smallwebwaf healthcheck`, which takes no
further argument, is the image's HEALTHCHECK. script/example-app builds
an app on the image and checks it end to end.

The Nix profile comes last on the PATH: first, busybox from nixpkgs
replaced runit's own runsvdir and sv. SPEC.md is corrected to match
what was built.

Model: opus-5-5
This commit is contained in:
2026-10-04 07:51:03 +00:00
parent 0750879e58
commit c15c329dce
17 changed files with 617 additions and 71 deletions
+93 -9
View File
@@ -33,16 +33,12 @@ RUN go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# Build stage, and the last one: a plain `docker build .` names no # Build stage. Nothing is wanted from the two phases above; the copies
# target and so builds this one. Nothing is wanted from the two phases # are what make BuildKit build them first, so the image, which needs this
# above; the copies are what make BuildKit build them first, so this # stage, cannot be produced unless lint and test passed.
# image cannot be produced unless lint and test passed. The image an
# app's Dockerfile builds FROM comes with milestone 2
# (https://git.eeqj.de/sneak/smallwebwaf/issues/12); until then this
# stage builds the binary and can run it.
# #
# golang 1.27.1-trixie, 2026-09-19 # golang 1.27.1-trixie, 2026-09-19
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
@@ -61,5 +57,93 @@ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf -o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
# runsvinit, the image's entrypoint, built at the last commit of its
# archived repository. It has no go.mod, and `go build` of its directory
# needs one; it uses only the standard library, so the one written here
# names nothing else.
#
# golang 1.27.1-trixie, 2026-09-19
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS runsvinit
RUN git clone --quiet https://github.com/peterbourgon/runsvinit /src
WORKDIR /src
# runsvinit v2.0.0-8-gb4b2c78, 2015-10-07
RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
&& go mod init github.com/peterbourgon/runsvinit \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o /usr/local/bin/runsvinit .
# The image an app's Dockerfile builds FROM, described under "Deployment"
# in SPEC.md. It is the last stage, so a plain `docker build .` builds it.
#
# ubuntu 26.04, 2026-09-27
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
# 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.
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 \
&& 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 \
ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/*
# Nix run by root expects a group of build users, which nix-bin does not
# create; with the setting empty, root's builds run without them.
RUN mkdir /etc/nix && echo 'build-users-group =' > /etc/nix/nix.conf
# nixpkgs, from its release file, checked by SHA-256, and set up for root
# as `nixpkgs`, so that an app's Dockerfile installs a package with
# `nix-env -iA nixpkgs.<name>`. curl and xz come with nix-bin.
#
# nixpkgs nixos-26.05.11045.774debe7a0d1, 2026-10-02
RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
https://releases.nixos.org/nixos/26.05/nixos-26.05.11045.774debe7a0d1/nixexprs.tar.xz \
&& echo 'b2994104605601690023a5a6a3bb5a07b2bd1716b4e3b208cba1056dacd2ab08 /tmp/nixexprs.tar.xz' \
| sha256sum --check --strict \
&& mkdir -p /root/.nix-defexpr/nixpkgs \
&& tar -xJf /tmp/nixexprs.tar.xz -C /root/.nix-defexpr/nixpkgs --strip-components=1 \
&& rm /tmp/nixexprs.tar.xz
# What root installs with nix-env lands in root's profile. This path to
# it works for every user, unlike /root/.nix-profile: only root can
# enter /root. It comes last, so that no package shadows the image's
# own tools: busybox, for one, brings an sv that looks for services
# elsewhere.
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
RUN groupadd --system --gid 65532 smallwebwaf \
&& useradd --system --uid 65532 --gid smallwebwaf --no-create-home \
--shell /usr/sbin/nologin smallwebwaf
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
# looks too.
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/smallwebwaf"]
# traefik sends a container no requests until it is healthy, so the
# check runs every second from the start until it first passes, for up
# to a minute, and every 30 seconds after that.
HEALTHCHECK --start-period=1m --start-interval=1s \
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/runsvinit"]
+7 -2
View File
@@ -1,8 +1,10 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks build run .PHONY: bootstrap setup test lint fmt fmt-check check docker hooks build run \
example-app
# 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
# of README.md). build and run are for working on the code by hand. # of README.md). build and run are for working on the code by hand;
# example-app checks the image with an app built on it.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -36,3 +38,6 @@ build:
run: run:
@script/run @script/run
example-app:
@script/example-app
+50 -30
View File
@@ -11,33 +11,38 @@ request with defaults chosen for a service on the open internet. It keeps its
state in memory and in JSON files you can read and edit, and writes a detailed state in memory and in JSON files you can read and edit, and writes a detailed
JSON log line for every request. JSON log line for every request.
Status: the first milestone is built Status: the first two milestones are built
(https://git.eeqj.de/sneak/smallwebwaf/issues/13), and the rate limits and (https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
country lists of the second (https://git.eeqj.de/sneak/smallwebwaf/issues/14). https://git.eeqj.de/sneak/smallwebwaf/issues/14). `smallwebwaf` passes each
`smallwebwaf` passes each request to the app and the app's answer back, request to the app and the app's answer back, unchanged, within its timeouts and
unchanged, within its timeouts and size limits, works out each client's address, size limits, works out each client's address, refuses a client that sends too
refuses a client that sends too many requests or comes from a country you many requests or comes from a country you refuse, and writes a JSON log line for
refuse, and writes a JSON log line for every request. The image an app builds on every request. It comes as the image the app's own image is built on. The rest
comes with the rest of milestone 2, and the rest of the design after that, in of the design comes after that, in the order of the build order in
the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing [`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md). [`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
`smallwebwaf` is one Go binary. Until milestone 2 brings its image, build and Build the `smallwebwaf` image from a clone:
run it from a clone, with Go installed:
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
cd smallwebwaf cd smallwebwaf
make build make docker
SWWAF_UPSTREAM_URL=http://127.0.0.1:3000 ./bin/smallwebwaf
``` ```
It then listens on port 8080 and passes every request to the app at `make docker` runs the tests and the linter, then builds the image, tagged
`SWWAF_UPSTREAM_URL`, here an app on port 3000; with no setting at all, to an `smallwebwaf`, for amd64 and only on an amd64 host: the hashes the `Dockerfile`
app on `127.0.0.1:8081`. On `SIGTERM` or `SIGINT` it stops taking requests and checks Ubuntu's package lists against are those of Ubuntu's amd64 archive. Push
gives those in progress five seconds to finish. it to a registry your hosts pull from, and build each app's image on it, pinned
by digest, as "How it works, in short" below shows. `make example-app` builds a
small app on the image, the one in `deploy/example-app`, and checks that it
works.
To work on the code, `make build` builds the binary alone, with Go installed,
and `make run` builds and runs it, listening on port 8080 in front of an app at
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`.
## What it does so far ## What it does so far
@@ -79,6 +84,8 @@ gives those in progress five seconds to finish.
lookup" below); with neither set, no visitor's address leaves the host. A lookup" below); with neither set, no visitor's address leaves the host. A
client on a private, loopback or link-local address has no country, and client on a private, loopback or link-local address has no country, and
neither list checks it. neither list checks it.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check.
- Writes a line in the request log for each request (see "Request log" below). - Writes a line in the request log for each request (see "Request log" below).
## Settings ## Settings
@@ -155,8 +162,9 @@ refused ones included:
- `action` is `forward` for a request passed to the app, `country_denied` for - `action` is `forward` for a request passed to the app, `country_denied` for
one refused for its client's country, `rate_limited` for one refused for a one refused for its client's country, `rate_limited` for one refused for a
rate limit, `too_large` for a request or response over its size limit, rate limit, `too_large` for a request or response over its size limit,
`timed_out` for one that ran out of time, and `upstream_error` when the app `timed_out` for one that ran out of time, `upstream_error` when the app could
could not be reached or its answer broke off. not be reached or its answer broke off, and `admin` for one `smallwebwaf`
answered at its own endpoint.
- `limit_hit` is there for a request refused for a rate limit, and names the - `limit_hit` is there for a request refused for a rate limit, and names the
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
went over several. went over several.
@@ -353,9 +361,9 @@ main "$@"
side by side, each as its own user, and start either again a second after it side by side, each as its own user, and start either again a second after it
exits. Leave out `ENTRYPOINT` and `USER` from the app's Dockerfile. exits. Leave out `ENTRYPOINT` and `USER` from the app's Dockerfile.
- `nix-env -iA nixpkgs.<name>` installs a package from the nixpkgs in the image, - `nix-env -iA nixpkgs.<name>` installs a package from the nixpkgs in the image,
and the app finds it on its `PATH`. That nixpkgs is fixed at one commit, so and the app finds it on its `PATH`, after Ubuntu's own commands. That nixpkgs
the same `smallwebwaf` image always gives the app the same packages; newer is fixed at one commit, so the same `smallwebwaf` image always gives the app
ones come with a newer `smallwebwaf` image. the same packages; newer ones come with a newer `smallwebwaf` image.
- Deploy it as you deploy any app, with traefik's labels on this one container - Deploy it as you deploy any app, with traefik's labels on this one container
pointing at port 8080. upaas needs no change for this. pointing at port 8080. upaas needs no change for this.
- The app has to trust `127.0.0.1` and `::1` for forwarded headers, besides the - The app has to trust `127.0.0.1` and `::1` for forwarded headers, besides the
@@ -373,7 +381,10 @@ main "$@"
the health check on `127.0.0.1`. the health check on `127.0.0.1`.
- `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf`. Mount a volume - `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf`. Mount a volume
there to keep bans and client history when a deploy replaces the container; there to keep bans and client history when a deploy replaces the container;
without one, it still starts. without one, it still starts. The state files come in milestone 3 or later;
until then it writes nothing to disk and needs no volume.
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
requests and gives those in progress five seconds to finish.
A rule file is one rule per line: a name, what to match against, what to do, and A rule file is one rule per line: a name, what to match against, what to do, and
a regex. a regex.
@@ -444,7 +455,8 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. - `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
- `internal/smallwebwaf`: the process: it reads the settings, listens, serves - `internal/smallwebwaf`: the process: it reads the settings, listens, serves
requests until `SIGTERM` or `SIGINT`, and stops. requests until `SIGTERM` or `SIGINT`, and stops. Run as
`smallwebwaf healthcheck`, it is the image's health check instead.
- `internal/config`: reads the settings, the one place they are read. - `internal/config`: reads the settings, the one place they are read.
- `internal/proxy`: what happens to each request: it works out the client, runs - `internal/proxy`: what happens to each request: it works out the client, runs
the checks, passes the request to the app and the answer back with the the checks, passes the request to the app and the answer back with the
@@ -458,6 +470,11 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
it over a rate limit. it over a rate limit.
- `internal/requestlog`: the lines on stdout: the request log line and the - `internal/requestlog`: the lines on stdout: the request log line and the
process's own messages. process's own messages.
- `Dockerfile`: the lint and test phases, then the image, whose last stage
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
`share/smallwebwaf.run` as runit's `run` script for `smallwebwaf`.
- `deploy/example-app`: an app built on the image, which `script/example-app`
checks.
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
@@ -472,7 +489,7 @@ the `Makefile` targets are thin shims that call them. The scripts are POSIX sh,
so that they run in minimal containers. so that they run in minimal containers.
- `script/bootstrap`: installs what the other scripts need on the host: `make`, - `script/bootstrap`: installs what the other scripts need on the host: `make`,
`git`, Go for `gofmt`, node and yarn, and prettier. `git`, `curl`, Go for `gofmt`, node and yarn, and prettier.
- `script/setup`: readies a fresh clone: runs `script/bootstrap`, then - `script/setup`: readies a fresh clone: runs `script/bootstrap`, then
`script/install-precommit`. `script/install-precommit`.
- `script/projectname`: prints the project's name, `smallwebwaf`, which - `script/projectname`: prints the project's name, `smallwebwaf`, which
@@ -492,13 +509,16 @@ so that they run in minimal containers.
working on the code by hand; `make build` runs it. working on the code by hand; `make build` runs it.
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it; - `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it;
`make run` runs it. `make run` runs it.
- `script/example-app`: builds the image and, on it, the example app in
`deploy/example-app`, runs it, and checks that the health check passes, that a
request reaches the app through `smallwebwaf`, and that `sv stop` and
`docker stop` stop it in order; then removes the container and both images. It
needs network access, for nixpkgs' binary cache, and `script/check` does not
run it; `make example-app` does.
## TODO ## TODO
- Milestone 2: the image an app builds on - Milestone 3 and the rest of the design, in the order of the build order in
(https://git.eeqj.de/sneak/smallwebwaf/issues/14); its rate limits and country
lists are built.
- The rest of the design, in the order of the build order in
[`SPEC.md`](SPEC.md). [`SPEC.md`](SPEC.md).
## Documents ## Documents
+37 -24
View File
@@ -1263,14 +1263,19 @@ image's digest does. `apt-get update` keeps the snapshot's `InRelease` files,
which apt checks against the archive's signature, in `/var/lib/apt/lists/`; each which apt checks against the archive's signature, in `/var/lib/apt/lists/`; each
lists the SHA-256 hash of the package lists it covers, and each package list the lists the SHA-256 hash of the package lists it covers, and each package list the
hash of every package in it. The Dockerfile also names the SHA-256 hash of each hash of every package in it. The Dockerfile also names the SHA-256 hash of each
`InRelease` file apt uses, and the build checks them after `apt-get update` and of the snapshot's `InRelease` files, and the build checks them after
before `apt-get install`, so every package apt installs is checked, through `apt-get update` and before `apt-get install`, so every package apt installs is
those files, against hashes the Dockerfile names. The snapshot service is checked, through those files, against hashes the Dockerfile names.
reached over HTTPS, and the Ubuntu image has no CA certificates of its own, so `apt-get update` also fetches the live archive's `InRelease` files into the same
this one install uses those of the Go image that `smallwebwaf` is built in, directory; their hashes change whenever the archive does, and the install does
which is pinned by digest too: apt's `Acquire::https::CaInfo` option names that not use them, so the check leaves them out. The hashes are those of the archive
image's CA certificate file, `/etc/ssl/certs/ca-certificates.crt`, copied into for amd64, and so the image is built for amd64: other architectures use Ubuntu's
the build. ports archive, whose `InRelease` files differ. The snapshot service is reached
over HTTPS, and the Ubuntu image has no CA certificates of its own, so this one
install uses those of the Go image that `smallwebwaf` is built in, which is
pinned by digest too: apt's `Acquire::https::CaInfo` option names that image's
CA certificate file, `/etc/ssl/certs/ca-certificates.crt`, mounted for that one
step.
Packages from nixpkgs: nixpkgs is fixed at one commit of its newest release Packages from nixpkgs: nixpkgs is fixed at one commit of its newest release
branch, `nixos-26.05` today. For each commit of the branch that has passed its branch, `nixos-26.05` today. For each commit of the branch that has passed its
@@ -1281,15 +1286,18 @@ bytes can change. The image's Dockerfile names the release and the SHA-256 hash
of that file, which the release's page lists, and the build checks the hash of that file, which the release's page lists, and the build checks the hash
before unpacking it. nixpkgs is set up for root under the name `nixpkgs`, so the before unpacking it. nixpkgs is set up for root under the name `nixpkgs`, so the
app's Dockerfile installs a package with `nix-env -iA nixpkgs.<name>`, and app's Dockerfile installs a package with `nix-env -iA nixpkgs.<name>`, and
whatever it installs is on the `PATH` of every service. Because nixpkgs stays at whatever it installs is on the `PATH` of every service: the image adds root's
that commit, an app built on the same `smallwebwaf` image gets the same packages Nix profile, `/nix/var/nix/profiles/default/bin`, at the end of the `PATH`,
each time it is built. Unpacked, nixpkgs takes about 500 MiB of disk, more on after Ubuntu's own directories, so that no package hides the image's own
some filesystems such as ZFS, and each package an app installs from it adds its commands. busybox, for one, brings its own `sv`, which looks for services
own size, with everything it depends on. A newer commit of the branch, with its elsewhere. Because nixpkgs stays at that commit, an app built on the same
security fixes, comes with a newer `smallwebwaf` image, as do Ubuntu's own `smallwebwaf` image gets the same packages each time it is built. Unpacked,
fixes; an app takes them by changing the digest in its `FROM` line. When nixpkgs nixpkgs takes about 500 MiB of disk, more on some filesystems such as ZFS, and
makes its next release, every six months, the image moves to that release's each package an app installs from it adds its own size, with everything it
branch. depends on. A newer commit of the branch, with its security fixes, comes with a
newer `smallwebwaf` image, as do Ubuntu's own fixes; an app takes them by
changing the digest in its `FROM` line. When nixpkgs makes its next release,
every six months, the image moves to that release's branch.
The two processes: The two processes:
@@ -1315,12 +1323,15 @@ The two processes:
- The container's root filesystem stays writable: runit writes each service's - The container's root filesystem stays writable: runit writes each service's
status into its directory under `/etc/service`. status into its directory under `/etc/service`.
The health check: the image's `HEALTHCHECK` passes while `smallwebwaf` answers The health check: the image's `HEALTHCHECK` runs `smallwebwaf healthcheck`,
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in `SWWAF_LISTEN_ADDR`, which passes while `smallwebwaf` answers `GET /_smallwebwaf/healthz` on
and the app accepts connections at the address in `SWWAF_UPSTREAM_URL`, and `127.0.0.1`, at the port in `SWWAF_LISTEN_ADDR`, and the app accepts connections
fails when either does not. The container therefore shows as healthy only while at the address in `SWWAF_UPSTREAM_URL`, and fails when either does not. The
both processes are up. An app with a health check of its own can replace the container therefore shows as healthy only while both processes are up. traefik
image's `HEALTHCHECK` with one that checks both. 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 minute, and
every 30 seconds after that. An app with a health check of its own can replace
the image's `HEALTHCHECK` with one that checks both.
Ports: `smallwebwaf` listens on port 8080 on every address and on no other port; Ports: `smallwebwaf` listens on port 8080 on every address and on no other port;
its health check, metrics and ban management are all on that listener, under its health check, metrics and ban management are all on that listener, under
@@ -1609,7 +1620,9 @@ holds any token file.
- The container image described under "Deployment", with runit and the - The container image described under "Deployment", with runit and the
container's health check. The health check calls `/_smallwebwaf/healthz`, container's health check. The health check calls `/_smallwebwaf/healthz`,
so milestone 2 answers that path, although the other admin endpoints come so milestone 2 answers that path, although the other admin endpoints come
later. later. The image's `/var/lib/smallwebwaf`, which the `run` script gives to
the `smallwebwaf` user, and `/etc/smallwebwaf/rules.d` come with the state
files and the rule files.
- Milestone 3 and later: the rest of the design, in this order: - Milestone 3 and later: the rest of the design, in this order:
- static lists, the bans that broken request limits lead to, the ban ledger - static lists, the bans that broken request limits lead to, the ban ledger
and the JSON state files with edits taken in while running, exemptions, and the JSON state files with edits taken in while running, exemptions,
+20
View File
@@ -0,0 +1,20 @@
# An app built on the smallwebwaf image, as under "Deployment" in
# SPEC.md, which script/example-app builds and checks. The app is
# busybox's web server, from the nixpkgs in the image, serving one page;
# a real app copies in its own binary instead.
#
# A real app names the smallwebwaf image by digest. This one takes the
# image script/example-app has just built, or else the one `make docker`
# builds.
ARG SMALLWEBWAF_IMAGE=smallwebwaf
FROM ${SMALLWEBWAF_IMAGE}
# Packages the app needs, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.busybox
# The app's page, and a user of its own to run it.
RUN mkdir /var/www && echo 'hello from the example app' > /var/www/index.html
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
main() {
sleep 1
exec chpst -u app:app busybox httpd -f -p 127.0.0.1:8081 -h /var/www
}
main "$@"
+47
View File
@@ -0,0 +1,47 @@
package proxy_test
import (
"net/http"
"sync/atomic"
"testing"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
t.Parallel()
var calls atomic.Int32
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
// With a limit of one request a minute, any request counted before
// the last one would have it refused.
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
const healthChecks = 3
for range healthChecks {
got := get(t, addr, proxy.HealthPath)
wantStatus(t, got, http.StatusOK)
if string(got.body) != "ok\n" {
t.Errorf("health endpoint answered %q, want ok", got.body)
}
}
wantStatus(t, get(t, addr, "/"), http.StatusOK)
lines := out.requestLines(t, healthChecks+1)
for _, line := range lines[:healthChecks] {
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
}
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
if calls.Load() != 1 {
t.Errorf("the app was called %d times, want once", calls.Load())
}
}
+14
View File
@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The request line and headers a client may send, and how long a // The request line and headers a client may send, and how long a
@@ -34,6 +35,10 @@ const (
appIdleConnTimeout = 90 * time.Second appIdleConnTimeout = 90 * time.Second
) )
// HealthPath is smallwebwaf's health endpoint, which the container's
// health check asks.
const HealthPath = "/_smallwebwaf/healthz"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
@@ -111,6 +116,15 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.finish() defer rq.finish()
// The health endpoint is answered at once, before any check, so that
// a health checker is never refused. It does not ask the app.
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
rq.line.Action = requestlog.ActionAdmin
_, _ = io.WriteString(rq.out, "ok\n")
return
}
refused := rq.check(r.Context()) refused := rq.check(r.Context())
if refused != nil { if refused != nil {
rq.answer(*refused) rq.answer(*refused)
+3
View File
@@ -28,6 +28,9 @@ const (
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
// 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"
// ActionAdmin is a request smallwebwaf answered at one of its own
// endpoints, under /_smallwebwaf/.
ActionAdmin = "admin"
) )
// timeLayout is RFC 3339 with milliseconds. // timeLayout is RFC 3339 with milliseconds.
+100
View File
@@ -0,0 +1,100 @@
package smallwebwaf
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy"
)
// healthCheckTimeout bounds the whole health check.
const healthCheckTimeout = 5 * time.Second
var errHealthEndpoint = errors.New("smallwebwaf's health endpoint answered")
// HealthCheck is the container's health check. It returns 0 while
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1.
// 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(
ctx context.Context, args []string, lookupEnv func(string) (string, bool),
stderr io.Writer,
) int {
if len(args) > 0 {
_, _ = fmt.Fprintf(stderr,
"smallwebwaf healthcheck: unexpected argument %q\n", args[0])
return 1
}
err := healthCheck(ctx, lookupEnv)
if err != nil {
_, _ = fmt.Fprintln(stderr, "unhealthy:", err)
return 1
}
return 0
}
func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) error {
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
cfg, err := config.FromEnvironment(lookupEnv)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
// The settings have checked that the address has a port.
_, port, _ := net.SplitHostPort(cfg.ListenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
if err != nil {
return fmt.Errorf("make the request: %w", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("ask smallwebwaf: %w", err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
}
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(cfg.UpstreamURL))
if err != nil {
return fmt.Errorf("connect to the app: %w", err)
}
_ = conn.Close()
return nil
}
// appAddress is the host and port of the app's URL, the port being the
// scheme's own when the URL names none.
func appAddress(app *url.URL) string {
port := app.Port()
if port == "" {
port = "80"
if app.Scheme == "https" {
port = "443"
}
}
return net.JoinHostPort(app.Hostname(), port)
}
@@ -0,0 +1,27 @@
package smallwebwaf
import (
"net/url"
"testing"
)
func TestAppAddress(t *testing.T) {
t.Parallel()
for app, want := range map[string]string{
"http://127.0.0.1:8081": "127.0.0.1:8081",
"http://app": "app:80",
"https://app/": "app:443",
"https://[::1]": "[::1]:443",
} {
parsed, err := url.Parse(app)
if err != nil {
t.Fatalf("parse %q: %v", app, err)
}
got := appAddress(parsed)
if got != want {
t.Errorf("appAddress(%q) is %q, want %q", app, got, want)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package smallwebwaf_test
import (
"bytes"
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
)
func TestHealthCheck(t *testing.T) {
t.Parallel()
app := httptest.NewServer(http.NotFoundHandler())
defer app.Close()
ctx, stop := context.WithCancel(t.Context())
defer stop()
out := &output{}
exited := make(chan int, 1)
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
upstreamURL: app.URL,
}, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
_, port, _ := net.SplitHostPort(addr)
// The container's settings: an address to listen on with an empty
// host part, which the health check asks at 127.0.0.1.
env := map[string]string{listenAddr: ":" + port, upstreamURL: app.URL}
wantHealthCheck(t, env, 0, "")
app.Close()
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ")
stop()
select {
case <-exited:
case <-time.After(waitLimit):
t.Fatal("still running after being told to stop")
}
wantHealthCheck(t, env, 1, "unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
}
func TestHealthCheckRefusesAnArgument(t *testing.T) {
t.Parallel()
var stderr bytes.Buffer
noSettings := func(string) (string, bool) {
return "", false
}
got := smallwebwaf.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
want := "smallwebwaf healthcheck: unexpected argument \"now\"\n"
if got != 1 || stderr.String() != want {
t.Errorf("health check returned %d and wrote %q, want 1 and %q",
got, stderr.String(), want)
}
}
// wantHealthCheck runs the health check with the settings in env, and
// checks its exit status and the start of what it writes to stderr,
// which is nothing when message is empty.
func wantHealthCheck(t *testing.T, env map[string]string, status int, message string) {
t.Helper()
var stderr bytes.Buffer
got := smallwebwaf.HealthCheck(t.Context(), nil, func(name string) (string, bool) {
value, ok := env[name]
return value, ok
}, &stderr)
wrote := stderr.String()
if got != status || !strings.HasPrefix(wrote, message) ||
(message == "" && wrote != "") {
t.Errorf("health check returned %d and wrote %q, want %d and %q",
got, wrote, status, message)
}
}
+6 -1
View File
@@ -37,8 +37,13 @@ type Params struct {
} }
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the // Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
// process's exit status. // process's exit status. Run as `smallwebwaf healthcheck`, it is the
// container's health check instead.
func Main(version string) int { func Main(version string) int {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
}
ctx, stop := signal.NotifyContext(context.Background(), ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGTERM, os.Interrupt) syscall.SIGTERM, os.Interrupt)
defer stop() defer stop()
+6 -5
View File
@@ -24,8 +24,9 @@ const (
// testVersion is the version the tests give smallwebwaf. // testVersion is the version the tests give smallwebwaf.
testVersion = "test" testVersion = "test"
// localhost is where the tests listen. // localhost is where the tests listen.
localhost = "127.0.0.1" localhost = "127.0.0.1"
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWWAF_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL"
) )
// output collects what smallwebwaf writes on stdout. // output collects what smallwebwaf writes on stdout.
@@ -143,8 +144,8 @@ func TestServesUntilToldToStop(t *testing.T) {
go func() { go func() {
exited <- run(ctx, map[string]string{ exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
"SWWAF_UPSTREAM_URL": app.URL, upstreamURL: app.URL,
}, out) }, out)
}() }()
@@ -177,7 +178,7 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
settings, _ := line["settings"].(map[string]any) settings, _ := line["settings"].(map[string]any)
want := map[string]any{ want := map[string]any{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
"SWWAF_UPSTREAM_URL": appURL, upstreamURL: appURL,
"SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16", "SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s", "SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m", "SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
+1
View File
@@ -131,6 +131,7 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing gofmt; then pkg_install go golang go go; fi if missing gofmt; then pkg_install go golang go go; fi
ensure_node ensure_node
+88
View File
@@ -0,0 +1,88 @@
#!/bin/sh
# script/example-app: build the image, and on it the example app in
# deploy/example-app, then run the app's container and check that the
# health check passes, that a request is served through smallwebwaf,
# that `sv stop` stops smallwebwaf in order, and that `docker stop`
# stops the container without having to kill it. The container and both
# images are removed however the script ends. Building the app needs
# network access, for nixpkgs' binary cache. script/check does not run
# this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-example-$$"
IMAGE="$NAME-base"
APP_IMAGE="$NAME-app"
CONTAINER="$NAME"
cleanup() {
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
}
fail() {
echo "example-app: $*; the container's output:" >&2
docker logs "$CONTAINER" >&2 || true
exit 1
}
# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
failure="$1"
shift
tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || fail "$failure"
sleep 1
done
}
healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER")"
[ "$status" = healthy ]
}
# logged <text>: the container's output holds text.
logged() {
docker logs "$CONTAINER" 2>&1 | grep -qF "$1"
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
docker build --no-cache -t "$IMAGE" .
docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \
-t "$APP_IMAGE" deploy/example-app
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \
"$APP_IMAGE" >/dev/null
wait_for "the health check did not pass" healthy
echo "example-app: the health check passes"
address="$(docker port "$CONTAINER" 8080/tcp)"
page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
fail "no answer on port 8080"
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
wait_for "smallwebwaf logged no request it forwarded" logged '"action":"forward"'
echo "example-app: smallwebwaf passes a request to the app and its answer back"
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null ||
fail "sv stop smallwebwaf failed"
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"'
echo "example-app: sv stop stops smallwebwaf in order"
docker stop "$CONTAINER" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$CONTAINER")"
[ "$status" = 0 ] || fail "docker stop left exit status $status"
echo "example-app: docker stop stops the container in order"
}
main "$@"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# runit's run script for smallwebwaf, run again whenever smallwebwaf
# exits; the wait spaces out the restarts. exec, so that the signal
# `sv stop` sends reaches smallwebwaf itself.
main() {
sleep 1
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
}
main "$@"