diff --git a/Dockerfile b/Dockerfile index 84da0ff..92834b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.`. 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"] diff --git a/Makefile b/Makefile index e8da879..36cf289 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 0b41fd2..58bf3d3 100644 --- a/README.md +++ b/README.md @@ -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.` 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 diff --git a/SPEC.md b/SPEC.md index 3020a65..63d236b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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.`, 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, diff --git a/deploy/example-app/Dockerfile b/deploy/example-app/Dockerfile new file mode 100644 index 0000000..a228c17 --- /dev/null +++ b/deploy/example-app/Dockerfile @@ -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 diff --git a/deploy/example-app/app.run b/deploy/example-app/app.run new file mode 100755 index 0000000..81782d8 --- /dev/null +++ b/deploy/example-app/app.run @@ -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 "$@" diff --git a/internal/proxy/health_test.go b/internal/proxy/health_test.go new file mode 100644 index 0000000..eacf49e --- /dev/null +++ b/internal/proxy/health_test.go @@ -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()) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 531478b..03d27b6 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -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) diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index fddcd19..1549c8f 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -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. diff --git a/internal/smallwebwaf/healthcheck.go b/internal/smallwebwaf/healthcheck.go new file mode 100644 index 0000000..e4fbeef --- /dev/null +++ b/internal/smallwebwaf/healthcheck.go @@ -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) +} diff --git a/internal/smallwebwaf/healthcheck_internal_test.go b/internal/smallwebwaf/healthcheck_internal_test.go new file mode 100644 index 0000000..7aec7f9 --- /dev/null +++ b/internal/smallwebwaf/healthcheck_internal_test.go @@ -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) + } + } +} diff --git a/internal/smallwebwaf/healthcheck_test.go b/internal/smallwebwaf/healthcheck_test.go new file mode 100644 index 0000000..d83b0ce --- /dev/null +++ b/internal/smallwebwaf/healthcheck_test.go @@ -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) + } +} diff --git a/internal/smallwebwaf/smallwebwaf.go b/internal/smallwebwaf/smallwebwaf.go index 97e1047..a78de0e 100644 --- a/internal/smallwebwaf/smallwebwaf.go +++ b/internal/smallwebwaf/smallwebwaf.go @@ -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() diff --git a/internal/smallwebwaf/smallwebwaf_test.go b/internal/smallwebwaf/smallwebwaf_test.go index b65fedf..a38505b 100644 --- a/internal/smallwebwaf/smallwebwaf_test.go +++ b/internal/smallwebwaf/smallwebwaf_test.go @@ -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", diff --git a/script/bootstrap b/script/bootstrap index d0e97bc..967bf12 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -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 diff --git a/script/example-app b/script/example-app new file mode 100755 index 0000000..63afabb --- /dev/null +++ b/script/example-app @@ -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 ...: 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 : 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 "$@" diff --git a/share/smallwebwaf.run b/share/smallwebwaf.run new file mode 100755 index 0000000..ba912de --- /dev/null +++ b/share/smallwebwaf.run @@ -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 "$@"