The image apps build FROM, with its health check (closes #45)
check / check (push) Failing after 3s
check / check (push) Failing after 3s
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` 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:
+90
-9
@@ -33,16 +33,12 @@ RUN go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage, and the last one: a plain `docker build .` names no
|
||||
# target and so builds this one. Nothing is wanted from the two phases
|
||||
# above; the copies are what make BuildKit build them first, so this
|
||||
# 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.
|
||||
# Build stage. Nothing is wanted from the two phases above; the copies
|
||||
# are what make BuildKit build them first, so the image, which needs this
|
||||
# stage, cannot be produced unless lint and test passed.
|
||||
#
|
||||
# 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=test /src/go.sum /dev/null
|
||||
@@ -61,5 +57,90 @@ RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-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
|
||||
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.
|
||||
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
|
||||
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"]
|
||||
|
||||
@@ -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/
|
||||
# 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:
|
||||
@script/bootstrap
|
||||
@@ -36,3 +38,6 @@ build:
|
||||
|
||||
run:
|
||||
@script/run
|
||||
|
||||
example-app:
|
||||
@script/example-app
|
||||
|
||||
@@ -11,33 +11,36 @@ 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
|
||||
JSON log line for every request.
|
||||
|
||||
Status: the first milestone is built
|
||||
(https://git.eeqj.de/sneak/smallwebwaf/issues/13), and the rate limits and
|
||||
country lists of the second (https://git.eeqj.de/sneak/smallwebwaf/issues/14).
|
||||
`smallwebwaf` passes each request to the app and the app's answer back,
|
||||
unchanged, within its timeouts and size limits, works out each client's address,
|
||||
refuses a client that sends too many requests or comes from a country you
|
||||
refuse, and writes a JSON log line for every request. The image an app builds on
|
||||
comes with the rest of milestone 2, and the rest of the design after that, in
|
||||
the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing
|
||||
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
|
||||
Status: the first two milestones are built
|
||||
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
|
||||
https://git.eeqj.de/sneak/smallwebwaf/issues/14). `smallwebwaf` passes each
|
||||
request to the app and the app's answer back, unchanged, within its timeouts and
|
||||
size limits, works out each client's address, refuses a client that sends too
|
||||
many requests or comes from a country you refuse, and writes a JSON log line for
|
||||
every request. It comes as the image the app's own image is built on. The rest
|
||||
of the design comes after that, in the order of the build order in
|
||||
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
|
||||
[`EVALUATION.md`](EVALUATION.md).
|
||||
|
||||
## Getting started
|
||||
|
||||
`smallwebwaf` is one Go binary. Until milestone 2 brings its image, build and
|
||||
run it from a clone, with Go installed:
|
||||
Build the `smallwebwaf` image from a clone:
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||
cd smallwebwaf
|
||||
make build
|
||||
SWWAF_UPSTREAM_URL=http://127.0.0.1:3000 ./bin/smallwebwaf
|
||||
make docker
|
||||
```
|
||||
|
||||
It then listens on port 8080 and passes every request to the app at
|
||||
`SWWAF_UPSTREAM_URL`, here an app on port 3000; with no setting at all, to an
|
||||
app on `127.0.0.1:8081`. On `SIGTERM` or `SIGINT` it stops taking requests and
|
||||
gives those in progress five seconds to finish.
|
||||
`make docker` runs the tests and the linter, then builds the image, tagged
|
||||
`smallwebwaf`, for amd64. Push 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
|
||||
|
||||
@@ -79,6 +82,8 @@ gives those in progress five seconds to finish.
|
||||
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
|
||||
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).
|
||||
|
||||
## Settings
|
||||
@@ -155,8 +160,9 @@ refused ones included:
|
||||
- `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
|
||||
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
|
||||
could not be reached or its answer broke off.
|
||||
`timed_out` for one that ran out of time, `upstream_error` when the app could
|
||||
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
|
||||
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
||||
went over several.
|
||||
@@ -353,9 +359,9 @@ main "$@"
|
||||
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.
|
||||
- `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
|
||||
the same `smallwebwaf` image always gives the app the same packages; newer
|
||||
ones come with a newer `smallwebwaf` image.
|
||||
and the app finds it on its `PATH`, after Ubuntu's own commands. That nixpkgs
|
||||
is fixed at one commit, so the same `smallwebwaf` image always gives the app
|
||||
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
|
||||
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
|
||||
@@ -373,7 +379,10 @@ main "$@"
|
||||
the health check on `127.0.0.1`.
|
||||
- `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;
|
||||
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 regex.
|
||||
@@ -444,7 +453,8 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
|
||||
|
||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||
- `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/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
|
||||
@@ -458,6 +468,11 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
|
||||
it over a rate limit.
|
||||
- `internal/requestlog`: the lines on stdout: the request log line and the
|
||||
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
|
||||
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
|
||||
@@ -472,7 +487,7 @@ the `Makefile` targets are thin shims that call them. The scripts are POSIX sh,
|
||||
so that they run in minimal containers.
|
||||
|
||||
- `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/install-precommit`.
|
||||
- `script/projectname`: prints the project's name, `smallwebwaf`, which
|
||||
@@ -492,13 +507,16 @@ so that they run in minimal containers.
|
||||
working on the code by hand; `make build` runs it.
|
||||
- `script/run`: builds `bin/smallwebwaf` with `script/build` and 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
|
||||
|
||||
- Milestone 2: the image an app builds on
|
||||
(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
|
||||
- Milestone 3 and the rest of the design, in the order of the build order in
|
||||
[`SPEC.md`](SPEC.md).
|
||||
|
||||
## Documents
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`InRelease` file apt uses, and the build checks them after `apt-get update` and
|
||||
before `apt-get install`, so every package apt installs is checked, through
|
||||
those files, against hashes the Dockerfile names. 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`, copied into
|
||||
the build.
|
||||
of the snapshot's `InRelease` files, and the build checks them after
|
||||
`apt-get update` and before `apt-get install`, so every package apt installs is
|
||||
checked, through those files, against hashes the Dockerfile names.
|
||||
`apt-get update` also fetches the live archive's `InRelease` files into the same
|
||||
directory; their hashes change whenever the archive does, and the install does
|
||||
not use them, so the check leaves them out. The hashes are those of the archive
|
||||
for amd64, and so the image is built for amd64: other architectures use Ubuntu's
|
||||
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
|
||||
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
|
||||
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
|
||||
whatever it installs is on the `PATH` of every service. Because nixpkgs stays at
|
||||
that commit, an app built on the same `smallwebwaf` image gets the same packages
|
||||
each time it is built. Unpacked, nixpkgs takes about 500 MiB of disk, more on
|
||||
some filesystems such as ZFS, and each package an app installs from it adds its
|
||||
own size, with everything it 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.
|
||||
whatever it installs is on the `PATH` of every service: the image adds root's
|
||||
Nix profile, `/nix/var/nix/profiles/default/bin`, at the end of the `PATH`,
|
||||
after Ubuntu's own directories, so that no package hides the image's own
|
||||
commands. busybox, for one, brings its own `sv`, which looks for services
|
||||
elsewhere. Because nixpkgs stays at that commit, an app built on the same
|
||||
`smallwebwaf` image gets the same packages each time it is built. Unpacked,
|
||||
nixpkgs takes about 500 MiB of disk, more on some filesystems such as ZFS, and
|
||||
each package an app installs from it adds its own size, with everything it
|
||||
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:
|
||||
|
||||
@@ -1315,12 +1323,15 @@ The two processes:
|
||||
- The container's root filesystem stays writable: runit writes each service's
|
||||
status into its directory under `/etc/service`.
|
||||
|
||||
The health check: the image's `HEALTHCHECK` passes while `smallwebwaf` answers
|
||||
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in `SWWAF_LISTEN_ADDR`,
|
||||
and the app accepts connections at the address in `SWWAF_UPSTREAM_URL`, and
|
||||
fails when either does not. The container therefore shows as healthy only while
|
||||
both processes are up. An app with a health check of its own can replace the
|
||||
image's `HEALTHCHECK` with one that checks both.
|
||||
The health check: the image's `HEALTHCHECK` runs `smallwebwaf healthcheck`,
|
||||
which passes while `smallwebwaf` answers `GET /_smallwebwaf/healthz` on
|
||||
`127.0.0.1`, at the port in `SWWAF_LISTEN_ADDR`, and the app accepts connections
|
||||
at the address in `SWWAF_UPSTREAM_URL`, and fails when either does not. The
|
||||
container therefore shows as healthy only while both processes are up. 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 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;
|
||||
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
|
||||
container's health check. The health check calls `/_smallwebwaf/healthz`,
|
||||
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:
|
||||
- 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,
|
||||
|
||||
@@ -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
|
||||
Executable
+9
@@ -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 "$@"
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"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
|
||||
@@ -34,6 +35,10 @@ const (
|
||||
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.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
@@ -111,6 +116,15 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
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())
|
||||
if refused != nil {
|
||||
rq.answer(*refused)
|
||||
|
||||
@@ -28,6 +28,9 @@ const (
|
||||
ActionRateLimited = "rate_limited"
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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.
|
||||
func HealthCheck(
|
||||
ctx context.Context, lookupEnv func(string) (string, bool), stderr io.Writer,
|
||||
) int {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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: ")
|
||||
}
|
||||
|
||||
// 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(), 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)
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,13 @@ type Params struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(os.Args) == 2 && os.Args[1] == "healthcheck" {
|
||||
return HealthCheck(context.Background(), os.LookupEnv, os.Stderr)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
@@ -24,8 +24,9 @@ const (
|
||||
// testVersion is the version the tests give smallwebwaf.
|
||||
testVersion = "test"
|
||||
// localhost is where the tests listen.
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
upstreamURL = "SWWAF_UPSTREAM_URL"
|
||||
)
|
||||
|
||||
// output collects what smallwebwaf writes on stdout.
|
||||
@@ -143,8 +144,8 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
"SWWAF_UPSTREAM_URL": app.URL,
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: app.URL,
|
||||
}, out)
|
||||
}()
|
||||
|
||||
@@ -177,7 +178,7 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
|
||||
settings, _ := line["settings"].(map[string]any)
|
||||
want := map[string]any{
|
||||
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_CLIENT_REQUEST_TIMEOUT": "60s",
|
||||
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
|
||||
|
||||
@@ -131,6 +131,7 @@ main() {
|
||||
|
||||
if missing make; then pkg_install gnumake make make make; 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
|
||||
|
||||
ensure_node
|
||||
|
||||
Executable
+88
@@ -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 "$@"
|
||||
Executable
+12
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user