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 ---"; \
|
{ 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,90 @@ 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
|
||||||
|
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
|
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/
|
# 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
|
||||||
|
|||||||
@@ -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
|
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. Push it to a registry your hosts pull from, and build
|
||||||
app on `127.0.0.1:8081`. On `SIGTERM` or `SIGINT` it stops taking requests and
|
each app's image on it, pinned by digest, as "How it works, in short" below
|
||||||
gives those in progress five seconds to finish.
|
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 +82,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 +160,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 +359,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 +379,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 +453,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 +468,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 +487,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 +507,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
// 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) == 2 && os.Args[1] == "healthcheck" {
|
||||||
|
return HealthCheck(context.Background(), 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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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