diff --git a/.dockerignore b/.dockerignore index 5565f6c..c4f5f96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,23 +1,84 @@ -# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier -# that keeps the check stages from replaying a cached pass. See the lint -# stage of the Dockerfile. -.git/ -bin/ -# Third-party browser assets are fetched and hash-verified inside the build by -# script/fetch-assets. Excluding any host copy keeps a developer's working tree -# from supplying the bytes that get shipped. The script and its -# static/vendor.sha256 manifest stay in the context. -static/js/alpine.min.js -*.md -LICENSE -.editorconfig -.env -.env.* -*.db -*.sqlite -*.sqlite3 -.DS_Store -.idea/ -.vscode/ -tmp/ -temp/ +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's own host-built artifacts: the binary `make build` +# writes, and the Alpine.js file `make assets` extracts from 3p/ (the +# build extracts its own). +/bin +/static/js/alpine.min.js + +# SQLite databases, which hold the session key and webhook payloads, at +# any depth. +**/*.db +**/*.sqlite +**/*.sqlite3 diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..92ec261 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ insert_final_newline = true [Makefile] indent_style = tab + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 562ec31..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,43 +1,9 @@ name: check - -on: - push: - branches: - - '**' - +on: [push] jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 - with: - # The fingerprint step below needs history to find the last commit - # that touched the Docker build context, and the superseded-status - # step needs it to walk ancestors (it aborts on a shallow clone). - fetch-depth: 0 - - - name: Mark superseded run statuses - # Gitea cancels the in-flight run when another commit is pushed to the - # same branch and records the cancellation as `failure`, so a commit - # that was never tested reads as a test result. The script rewrites - # those statuses to say what happened. See its header for why the - # state stays `failure` and not `skipped`. - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - run: script/ci-mark-superseded - - - name: Fingerprint the build context - # `.dockerignore` keeps docs out of the build context, so a docs-only - # commit legitimately replays the whole image from cache and stays - # cheap. Every other commit writes a new fingerprint into the context, - # which invalidates the `COPY . .` layer of both check stages: a - # commit that was never linted, formatted-checked, tested and built - # cannot report success from cache. - run: | - set -eu - fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')" - printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint - - - name: Build Docker image (runs make check) - run: script/cibuild + check: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/cibuild diff --git a/.gitignore b/.gitignore index 50cd133..e94615b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,53 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node +node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, after the shared content above. + # Binaries *.exe *.dll @@ -15,21 +65,6 @@ bin/ # Go vendor directory vendor/ -# IDE specific files -.idea/ -*.swp -*.swo -*~ -.vscode/ - -# OS specific files -.DS_Store -Thumbs.db - -# Environment and config files -.env -.env.local - # Data directory (SQLite databases) data/ *.db @@ -43,10 +78,6 @@ data/ tmp/ temp/ -# CI cache barrier, written into the build context by the check workflow -.ci-fingerprint - -# Third-party browser assets, fetched and hash-verified by -# script/fetch-assets against static/vendor.sha256. Not committed: -# REPO_POLICIES.md forbids minified bundles in version control. -/static/js/alpine.min.js \ No newline at end of file +# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is +# what is committed. +/static/js/alpine.min.js diff --git a/.golangci.yml b/.golangci.yml index 26b1610..80801b5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,14 +10,21 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -28,6 +35,70 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + - pkg: sneak.berlin/go/webhooker/internal/config/configtest + desc: test support; a file that is not a test must not import it + - pkg: sneak.berlin/go/webhooker/internal/database/databasetest + desc: test support; a file that is not a test must not import it + - pkg: sneak.berlin/go/webhooker/internal/middleware/middlewaretest + desc: test support; a file that is not a test must not import it + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..6d8ebc7 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,3 @@ +# Install into node_modules/: the Dockerfile's lint and Markdown stages run +# ESLint and prettier from node_modules/.bin. +nodeLinker: node-modules diff --git a/3p/alpinejs-csp-3.14.9.tgz b/3p/alpinejs-csp-3.14.9.tgz new file mode 100644 index 0000000..f35c940 Binary files /dev/null and b/3p/alpinejs-csp-3.14.9.tgz differ diff --git a/Dockerfile b/Dockerfile index 907ae40..e72dee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,84 @@ -# Lint stage -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +# Stylesheet stages. static/css/tailwind.css is generated, by this pinned +# tailwindcss, from static/css/input.css and the files its @source lines +# name. `make css` (script/css) writes it out from the css-output stage. +# The css-check stage fails when the committed file differs from what is +# generated; `make check` runs it, and so does the build stage below. +# +# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per +# architecture, each pinned by its sha256 from the release's sha256sums.txt. +# debian:bookworm-slim, 2026-10-02: the binary needs glibc. +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64 +ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \ + https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss + +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64 +ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \ + https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss + +# TARGETARCH, set by docker, is the architecture being built for. +FROM tailwind-${TARGETARCH} AS css +WORKDIR /src +COPY . . +RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify + +FROM scratch AS css-output +COPY --from=css /out/tailwind.css / + +# Both files are split after each "}", one rule per line, so that when they +# differ the diff shows the rules that differ. +FROM css AS css-check +RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ + && sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \ + && diff -U0 /tmp/committed.css /tmp/generated.css || { \ + echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \ + exit 1; \ + } + +# JavaScript lint stages: ESLint, at the version package.json and yarn.lock +# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and +# prettier for the Markdown stages below. The lint phase below runs js-lint. +# +# The image's own corepack runs the yarn that package.json's packageManager +# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the +# hash there. The image also ships yarn 1, which `corepack enable yarn` +# replaces. +# node:24.21.0-alpine (LTS), 2026-09-18 +FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps +WORKDIR /src +COPY package.json yarn.lock .yarnrc.yml ./ +RUN corepack enable yarn && yarn install --immutable --mode=skip-build + +FROM js-deps AS js-lint +COPY . . +RUN --network=none node_modules/.bin/eslint static/js + +# Markdown stages: prettier, at the version package.json and yarn.lock pin, +# formats every Markdown file in the tree with the settings in .prettierrc. +# `make fmt` (script/fmt) writes the formatted files out from markdown-output. +# markdown-check fails on any file prettier would change; `make fmt-check` +# runs it, and so does the build stage below. +FROM js-deps AS markdown +COPY . . +RUN --network=none node_modules/.bin/prettier --write '**/*.md' \ + && mkdir /out \ + && find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \; + +FROM scratch AS markdown-output +COPY --from=markdown /out / + +FROM js-deps AS markdown-check +COPY . . +RUN --network=none node_modules/.bin/prettier --check '**/*.md' + +# Lint phase: the Go formatting check and golangci-lint over the Go code, +# and ESLint over static/js/ through the copy from js-lint at the end. +# `make lint` (script/lint) builds this stage alone; the build stage below +# depends on it. +# +# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 # Using Debian-based image because mattn/go-sqlite3 (CGO) does not # compile on Alpine musl (off64_t is a glibc type). -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint - -RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* +FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -12,34 +86,104 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -# Copy source code. In CI the context also carries .ci-fingerprint, whose -# value changes with every commit that touches the build context (see -# .gitea/workflows/check.yml). That invalidates this layer, so the checks -# below cannot report success by replaying a cached pass. Do not add it to -# .dockerignore. COPY . . -# Run formatting check and linter. golangci-lint is invoked directly rather -# than through `make lint`: this stage is already the pinned linter image, and -# script/lint is a wrapper that builds Dockerfile.lint, so calling it here -# would need a docker daemon inside the build. Keep these steps in step with -# Dockerfile.lint, including --network=none (see its header for why). -RUN make fmt-check +# gofmt and golangci-lint are invoked directly rather than through `make +# fmt-check` and `make lint`, which are themselves docker builds and would +# need a docker daemon inside this one. The Markdown half of `make +# fmt-check` is the markdown-check stage above. +RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi + +# static/static.go embeds the Alpine.js file this extracts from 3p/; without +# it the static package does not compile and cannot be linted. +RUN script/assets + +# The golangci-lint steps run with --network=none. `golangci-lint config +# verify` is documented as fetching its JSON schema over HTTPS; this pinned +# image resolves the schema without any network, and --network=none enforces +# that. It also proves no linter reaches out at analysis time. +# +# `run` silently ignores config keys it does not recognize, so a typo would +# disable a setting without a word. `config verify` is what catches that. RUN --network=none golangci-lint config verify --config .golangci.yml -RUN --network=none golangci-lint run --config .golangci.yml ./... +# --build-tags browser also lints the browser test, which is built only with +# that tag (make test-browser). +RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... + +# Nothing is wanted from js-lint; the copy is what makes this phase run it. +COPY --from=js-lint /src/yarn.lock /dev/null + +# Test phase. -race needs cgo and so a C compiler, which the Debian Go image +# ships and the alpine one does not. `make test` (script/test) builds this +# stage alone; the build stage below depends on it. +# +# golang:1.26.1-bookworm (Debian-based), 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# static/static.go embeds the Alpine.js file this extracts from 3p/. +RUN script/assets + +# -timeout applies to each package on its own, so 90s has only to clear the +# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most +# four test binaries build or run at once, each with at most eight parallel +# tests. Under -race every test binary and every link costs a few hundred MB, +# so the defaults (one per core) add up to several GB on a many-core host. +# +# The first run has no -v: go test then prints one result line per package, +# with its coverage, and for a package that fails, everything its tests +# wrote. Verbose output from the whole suite passes the 2 MiB at which the +# Docker build cuts off a step's log, so on a failure only the tests that +# failed run again, with -v. go test reports a failed test as a line starting +# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with +# its parent) and a failed package as "FAILpackage/path...". A +# failure that names no test, such as a build error or a timeout, is already +# shown in full, so there is nothing to rerun. The step fails after the rerun +# whatever its result: the first run already showed the suite is broken. +# +# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite +# waits for the disk at every commit, and on a busy host that waiting was +# about 40% of the slowest package's run time. GOTMPDIR keeps go's own +# build files, the test binaries among them, on disk. +# +# bash with pipefail, so that the first run's status is go test's, not tee's. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ + export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \ + go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ + tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \ + packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \ + if [ -n "$tests" ]; then \ + echo "--- Rerunning the failed tests with -v for details ---"; \ + go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \ + fi; \ + exit 1 # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 # Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. +# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image +# ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder -# Depend on lint stage passing +# Nothing is wanted from the lint and test phases or from the stylesheet and +# Markdown checks; the copies are what make BuildKit build them first, so +# this stage cannot run unless they all passed. COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +COPY --from=css-check /out/tailwind.css /dev/null +COPY --from=markdown-check /src/yarn.lock /dev/null -# jq is a runtime dependency of script/ci-mark-superseded, which the test -# suite executes. -RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/* +# A build context sent as a tar archive keeps its files' owners, and git +# refuses to read a checkout owned by another user. Trust this one +# whoever owns it. +RUN git config --system --add safe.directory /build WORKDIR /build @@ -47,42 +191,45 @@ WORKDIR /build COPY go.mod go.sum ./ RUN go mod download -# Copy source code, including the .ci-fingerprint cache barrier described in -# the lint stage above. COPY . . -# Fetch the third-party browser assets the UI serves. They are not committed -# (REPO_POLICIES.md forbids minified bundles in version control) and -# .dockerignore keeps any host copy out of the build context, so this step is -# the only way they enter the image. Each download is checked against a -# hardcoded sha256 and the build fails on mismatch; make test re-checks the -# hashes against the bytes go:embed actually put in the binary. -RUN script/fetch-assets +# Version stamped into the binary: the VERSION build arg when one is +# given, otherwise what script/version derives from the .git the build +# context carries, so any `docker build .` of a clone stamps its commit. +# With neither, as from a source tarball, it is "unknown". +ARG VERSION -# Run tests and build -RUN make test - -# Version stamped into the binary. .dockerignore excludes .git/, so -# nothing in this stage can derive it: script/docker resolves it on the -# host and passes it in. The default is what a bare `docker build .` -# with no --build-arg gets, and it names no tag the tree may not be at. -# -# Declared here, below the test and asset steps, so a changed version -# does not invalidate their cached layers. -ARG VERSION=unknown +# A context that carries .git must not stamp an empty version, "dev" or +# "unknown": that means git is missing here or could not read the +# checkout, and the image could not be traced back to its commit. +RUN version="$(make version VERSION="$VERSION")"; \ + if [ -e .git ]; then \ + case "$version" in ""|dev|unknown) \ + echo "version is '$version' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi +# Builds through the Makefile's build target, which runs script/assets +# (Alpine.js, extracted from its tarball in 3p/) first. RUN make build VERSION="$VERSION" -# Rebuild with static linking for Alpine runtime. -# make build already verified compilation. -# The CGO binary from `make build` is dynamically linked against glibc, -# which doesn't exist on Alpine (musl). Rebuild with static linking so -# the binary runs on Alpine without glibc. +# Rebuild with static linking for the Alpine runtime. The cgo binary the +# step above builds is dynamically linked against glibc, which Alpine +# (musl) does not have. # -# The static flags go in through GO_LDFLAGS rather than a -ldflags of -# their own: the build target composes them with the -X that stamps the -# version, so this relink cannot silently drop the stamp. -RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"' +# A statically linked glibc still loads shared libraries at run time for +# getaddrinfo and dlopen, and Alpine has none to load, so the build tags +# keep both out of the binary: netgo and osusergo select Go's own DNS +# resolver and user lookup instead of glibc's, and +# sqlite_omit_load_extension leaves out SQLite extension loading, which +# webhooker never uses. +# +# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather +# than a -ldflags of their own: the build target composes them with the +# -X that stamps the version, so this relink cannot silently drop the +# stamp. +RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"' # Runtime stage # alpine:3.21, 2026-03-17 diff --git a/Dockerfile.browser b/Dockerfile.browser new file mode 100644 index 0000000..d86835e --- /dev/null +++ b/Dockerfile.browser @@ -0,0 +1,29 @@ +# Browser test image, built by script/test-browser (make test-browser). It +# runs the test in internal/server that loads the pages in a headless +# browser under the real Content-Security-Policy. That test is built only +# with the browser build tag, so make test leaves it out. Here the browser +# comes from a digest-pinned image, and if it is missing the test fails. + +# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile. +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# The test binary embeds the templates and static files, so the browser +# stage needs nothing else. -p 4 keeps the compile's memory down, as in +# the test phase of Dockerfile. +RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server + +# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The +# browser is on PATH as headless-shell, where the test's browser library +# looks for it. +FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser + +COPY --from=build /browser.test /browser.test + +RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$' diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 9a9af51..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,37 +0,0 @@ -# Lint-only image, built by script/lint. golangci-lint is never installed on -# the host: the repo is COPYed into the pinned image and linted as a build -# step, so a successful build IS a clean lint. This works even when the docker -# daemon is remote and bind mounts are impossible. -# -# script/lint passes --no-cache-filter=lint. Without it an unchanged tree -# replays the lint stage from cache and the build succeeds in under a second -# having run no linter at all. Do not drop that flag. -# -# The lint steps run with --network=none. `golangci-lint config verify` is -# documented as fetching its JSON schema over HTTPS, which would make linting -# depend on an unpinned remote artifact; this pinned image resolves the schema -# without any network, and --network=none enforces that rather than trusting -# it. It also proves no linter reaches out at analysis time. If a future image -# bump makes either step need the network, this build fails loudly instead of -# quietly acquiring an unpinned dependency. - -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps - -WORKDIR /src - -# Copy go mod files first for better layer caching. This stage is cacheable; -# only the lint stage below is forced to re-execute. -COPY go.mod go.sum ./ -RUN go mod download - -FROM deps AS lint - -COPY . . - -# `run` silently ignores config keys it does not recognize, so a typo would -# disable a setting without a word. `config verify` is what catches that. -RUN --network=none golangci-lint config verify --config .golangci.yml -RUN --network=none golangci-lint run --config .golangci.yml ./... diff --git a/Makefile b/Makefile index ceed419..b2f2f36 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,31 @@ -.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version +.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version # Default target .DEFAULT_GOAL := check # Version stamped into the binary. Derived from git by script/version; -# override it (`make build VERSION=v1.2.3`) where git metadata is -# unavailable, which is how the Dockerfile passes its build arg in. +# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is +# how the Dockerfile passes its build arg in. VERSION ?= $(shell script/version) -# An empty override (`make build VERSION=`, or a `--build-arg VERSION=` -# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset, +# An empty override (`make build VERSION=`, or the Dockerfile's `make build +# VERSION="$VERSION"` when no VERSION build arg was given) means unset, # exactly as it does in script/version -- stamping "" would leave the binary # reporting no version and the footer back on its "dev" fallback. `override` # is required: a plain assignment loses to the command-line definition it # exists to correct. override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) -# Extra linker flags for the build target. The static relink in the -# Dockerfile adds -extldflags here rather than passing its own -ldflags, -# so composing flags cannot drop the version stamp. +# Extra linker flags and build tags for the build target. The static +# relink in the Dockerfile passes -extldflags and its build tags through +# these rather than a -ldflags of its own, so composing flags cannot drop +# the version stamp. +# +# The build target itself always passes -trimpath and -s -w, as the Go +# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or +# debug information in the binary. GO_LDFLAGS ?= +GO_TAGS ?= bootstrap: @script/bootstrap @@ -28,11 +34,14 @@ setup: @script/setup assets: - @script/fetch-assets + @script/assets test: @script/test +test-browser: + @script/test-browser + lint: @script/lint @@ -45,13 +54,13 @@ fmt-check: check: @script/check -build: - go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker +build: assets + go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker -dev: +dev: assets go run ./cmd/webhooker deps: @@ -71,4 +80,7 @@ hooks: @script/install-precommit css: - tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify + @script/css + +css-check: + @script/css-check diff --git a/README.md b/README.md index c1bb19b..80ed3db 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ # webhooker -webhooker is a self-hosted webhook proxy and store-and-forward service -written in [Go](https://golang.org) by -[@sneak](https://sneak.berlin). It receives webhooks from external -services, durably stores them, and delivers them to configured targets -with retry support, logging, and observability. Category: infrastructure -/ web service. License: MIT. +webhooker is a self-hosted webhook proxy and store-and-forward service written +in [Go](https://golang.org) by [@sneak](https://sneak.berlin). It receives +webhooks from external services, durably stores them, and delivers them to +configured targets with retry support, logging, and observability. Category: +infrastructure / web service. License: MIT. -Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and -that UUID is the entrypoint's only credential. webhooker does not use -shared secrets, HMAC signatures or token headers on the receiver, and -will not add them — read +Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and that UUID is the +entrypoint's only credential. webhooker does not use shared secrets, HMAC +signatures or token headers on the receiver, and will not add them — read [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) before deploying one. @@ -19,15 +17,16 @@ before deploying one. ### Prerequisites - Go 1.26.1+ (the version in `go.mod`) -- Docker (for linting, for the test stage of the CI gate, and for - containerized deployment) -- `curl`, used by `script/fetch-assets` to download the third-party - browser assets, which are not committed (`make bootstrap` installs - it if missing) +- Docker (for `make test`, `make lint`, `make fmt` and `make css`, and so for + `make check`, for the browser test in `make test-browser`, for the CI gate, + and for containerized deployment) -golangci-lint is not a prerequisite and must not be installed on the -host: `script/bootstrap` does not install it, and `make lint` runs the -digest-pinned linter image via `Dockerfile.lint`. +golangci-lint is not a prerequisite and must not be installed on the host: +`script/bootstrap` does not install it, and `make lint` runs the digest-pinned +linter image in the Dockerfile's `lint` phase. The same holds for tailwindcss +(see [Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not +prerequisites either, and `make lint` and `make fmt` never use a host copy of +them (see [Linting](#linting)). ### Quick Start @@ -36,18 +35,17 @@ digest-pinned linter image via `Dockerfile.lint`. git clone https://git.eeqj.de/sneak/webhooker.git cd webhooker -# Install Go dependencies and the third-party browser assets. -# `make deps` alone is not enough: it only runs go mod download/tidy, -# and the checks below need the fetched assets. +# Install the Go toolchain if missing, and the Go dependencies make bootstrap -# Run all checks (test, lint, format check) +# Run all checks (test, lint, format check, stylesheet check) make check # Run the server from the clone. DATA_DIR defaults to # /var/lib/webhooker in every environment, so set it (in .env or the -# shell) to a writable directory. -DATA_DIR=./data make dev +# shell) to a writable directory outside the clone: the databases hold +# the session key. +DATA_DIR=../webhooker-data make dev # Build Docker image make docker @@ -58,12 +56,13 @@ make docker ```bash make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook -make assets # Fetch + verify third-party browser assets -make fmt # Format code (gofmt + goimports) -make fmt-check # Fail if gofmt would change anything (writes nothing) -make lint # Run golangci-lint in Docker (Dockerfile.lint) -make test # Run tests with race detection -make check # test + lint + fmt-check (CI gate) +make assets # Extract Alpine.js from 3p/ (build and dev run it) +make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker) +make fmt-check # Fail if gofmt or prettier would change anything (writes nothing) +make lint # Run golangci-lint and ESLint in Docker +make test # Run tests with race detection, in Docker +make test-browser # Run the browser test in Docker (Dockerfile.browser) +make check # test + lint + fmt-check + css-check (CI gate) make build # Build binary to bin/webhooker (version-stamped) make version # Print the version this checkout would stamp make run # build, then run ./bin/webhooker @@ -71,452 +70,444 @@ make dev # go run ./cmd/webhooker make deps # go mod download + go mod tidy make docker # Build Docker image make hooks # Install git pre-commit hook that runs script/precommit -make css # Regenerate static/css/tailwind.css (needs tailwindcss) +make css # Regenerate static/css/tailwind.css (tailwindcss in Docker) +make css-check # Fail if static/css/tailwind.css is stale (writes nothing) make clean # Remove bin/ ``` ### Configuration -All configuration is via environment variables. For local development, -you can place variables in a `.env` file in the process working -directory, read once at startup before anything else looks at the -environment. +All configuration is via environment variables. For local development, you can +place variables in a `.env` file in the process working directory, read once at +startup before anything else looks at the environment. -The file is optional and having none is the normal case for a -deployment. A file that is there but cannot be parsed aborts startup -with a message naming it, because a single malformed line makes none -of the file apply: every variable in it silently reverts to its -default, which is exactly the failure [Invalid values abort -startup](#invalid-values-abort-startup) exists to prevent, for all of -them at once. A variable already present in the real environment wins +The file is optional and having none is the normal case for a deployment. An +empty file is the same as none: it has nothing in it to apply. A file that is +there but cannot be parsed aborts startup with a message naming it, because a +single malformed line makes none of the file apply: every variable in it +silently reverts to its default, which is exactly the failure +[Invalid values abort startup](#invalid-values-abort-startup) exists to prevent, +for all of them at once. A variable already present in the real environment wins over the file's value for the same name. -The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` -or `prod` (default: `prod`; `dev` must be set explicitly). The setting -controls exactly one behavior: +The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` or +`prod` (default: `prod`; `dev` must be set explicitly). The setting controls +exactly one behavior: | Behavior | `dev` | `prod` | | -------- | ----------------------- | ---------------- | | CORS | Allows any origin (`*`) | Disabled (no-op) | -The environment setting does **not** control cookie security. Both the -session cookie and the CSRF cookie get their `Secure` flag, and the -CSRF middleware its Origin/Referer validation mode, from the transport -of each individual request, decided by one predicate — -`internal/reqtls.IsTLS`. It reports TLS for a direct TLS connection -(`r.TLS`) or for a TLS-terminating reverse proxy that reports one in -`X-Forwarded-Proto`: +The environment setting does **not** control cookie security. Both the session +cookie and the CSRF cookie get their `Secure` flag, and the CSRF middleware its +Origin/Referer validation mode, from the transport of each individual request, +decided by one predicate — `internal/reqtls.IsTLS`. It reports TLS for a direct +TLS connection (`r.TLS`) or for a TLS-terminating reverse proxy that reports one +in `X-Forwarded-Proto`: - **Direct TLS or `X-Forwarded-Proto: https`**: Secure cookies, strict Origin/Referer validation. -- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer - checks (token validation still enforced). +- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer checks (token + validation still enforced). -The `X-Forwarded-Proto` value is matched case-insensitively on its -first comma-separated element, trimmed, so `HTTPS` and the appended -chains a proxy behind another proxy emits (`https, http`) are all read -as TLS. +The `X-Forwarded-Proto` value is matched case-insensitively on its first +comma-separated element, trimmed, so `HTTPS` and the appended chains a proxy +behind another proxy emits (`https, http`) are all read as TLS. -This means both cookie security and CSRF protection work correctly in -all deployment scenarios: behind a TLS-terminating reverse proxy, with -direct TLS, or over plain HTTP during development — a plain-HTTP local -run gets non-`Secure` cookies and remains usable, and a proxied -deployment gets `Secure` ones without the operator setting anything. -When running behind a reverse proxy, ensure it sets the -`X-Forwarded-Proto: https` header. Unlike `X-Forwarded-For`, this -header is read from any peer and is **not** gated by -`TRUSTED_PROXIES`; a correctly configured proxy overwrites whatever a -client sent. On a listener exposed directly to clients, any client can -assert it, so do not run one without a proxy in front. +This means both cookie security and CSRF protection work correctly in all +deployment scenarios: behind a TLS-terminating reverse proxy, with direct TLS, +or over plain HTTP during development — a plain-HTTP local run gets non-`Secure` +cookies and remains usable, and a proxied deployment gets `Secure` ones without +the operator setting anything. When running behind a reverse proxy, ensure it +sets the `X-Forwarded-Proto: https` header. Unlike `X-Forwarded-For`, this +header is read from any peer and is **not** gated by `TRUSTED_PROXIES`; a +correctly configured proxy overwrites whatever a client sent. On a listener +exposed directly to clients, any client can assert it, so do not run one without +a proxy in front. -All other differences (log format, security headers, etc.) are -independent of the environment setting — log format is determined by -TTY detection, and security headers are always applied. +All other differences (log format, security headers, etc.) are independent of +the environment setting — log format is determined by TTY detection, and +security headers are always applied. -| Variable | Description | Default | -| ----------------------- | ----------------------------------- | -------- | -| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` | -| `PORT` | HTTP listen port | `8080` | -| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | -| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | -| `DEBUG` | Enable debug logging | `false` | -| `MAINTENANCE_MODE` | Report `maintenanceMode: true` in the healthcheck JSON. It does not change how any request is served — no maintenance page exists | `false` | -| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` | -| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` | -| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | -| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` | -| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | -| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | -| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) | -| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | +| Variable | Description | Default | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` | +| `PORT` | HTTP listen port | `8080` | +| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | +| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | +| `DEBUG` | Enable debug logging | `false` | +| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` | +| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` | +| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | +| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` | +| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | +| `HSTS_MAX_AGE` | `max-age` of the `Strict-Transport-Security` header, which also carries `includeSubDomains` and `preload` (Go duration, whole seconds). `0` sends no header. Read [HSTS](#hsts) before changing it | `87600h` (ten years) | +| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | +| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) | +| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | + +The Settings page of the web UI (`/settings`, behind the login) lists every one +of these with the value the running server loaded. It is read-only, and it shows +`METRICS_PASSWORD` and `SENTRY_DSN` only as set or not set, never their values. #### Allowing egress to your own network -By default every delivery target must resolve to a public address. The -private and reserved ranges — RFC 1918, loopback, CGNAT, link-local and -the rest — are refused, which stops a target from being used to make -webhooker probe the network it sits in. +By default every delivery target must resolve to a public address. The private +and reserved ranges — RFC 1918, loopback, CGNAT, link-local and the rest — are +refused, which stops a target from being used to make webhooker probe the +network it sits in. -Besides the private and reserved ranges, the default blocklist refuses -public cloud metadata addresses: currently only `168.63.129.16`, Azure's -WireServer, which serves an Azure VM its credentials. Because it is a -public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. +Besides the private and reserved ranges, the default blocklist refuses public +cloud metadata addresses: currently only `168.63.129.16`, Azure's WireServer, +which serves an Azure VM its credentials. Because it is a public address, +listing it in `ALLOWED_EGRESS_CIDRS` reopens it. -That default is also inconvenient for the thing webhooker is mostly -for: taking a public webhook and forwarding it to something on your own -network. A container on the same Docker network, a box on `10.x`, a -service on `127.0.0.1` — all refused, until you name them. +That is all the default blocklist covers: the IPv4 private and reserved ranges; +of IPv6, only loopback (`::1`), the unspecified address (`::`), unique local +addresses (`fc00::/7`), link-local addresses (`fe80::/10`), multicast +(`ff00::/8`) and documentation space (`2001:db8::/32`); and certain public +addresses. A public address belongs on the default blocklist only if it hands +credentials, user data or bootstrap material to whatever can reach it, without +the caller presenting anything. A provider's other public addresses are not +refused. IBM Cloud, for example, serves its package mirrors, time servers and +object storage on `161.26.0.0/16`, and the private endpoints of its own cloud +services on `166.8.0.0/14`. Neither range hands out credentials that way: the +token service among those endpoints issues a token only in exchange for +something the caller presents, such as an API key. Reaching these services can +be a legitimate delivery, and every cloud has some, so a partial list would +promise coverage it does not give. -`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare -address such as `10.0.0.7` is accepted and treated as a single host), -for example `10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks -become valid delivery destinations. Everything outside them keeps the -default answer, so this only ever adds destinations — it never removes -any, and it cannot narrow what was already reachable. +That default is also inconvenient for the thing webhooker is mostly for: taking +a public webhook and forwarding it to something on your own network. A container +on the same Docker network, a box on `10.x`, a service on `127.0.0.1` — all +refused, until you name them. -**The risk, plainly.** Each block you list is a network that anyone who -can create a delivery target can now make this process issue requests -into, and read the response body back out of via the delivery log. That -is server-side request forgery, deliberately enabled and scoped by you. -A webhooker admin account is therefore as trusted as the narrowest -thing on those networks: an unauthenticated admin panel, a database -listening without a password, or an internal API that trusts its -network position is reachable through it. List the smallest blocks that -cover the destinations you actually deliver to — prefer -`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the -network you are willing to expose. +`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare address +such as `10.0.0.7` is accepted and treated as a single host), for example +`10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks become valid delivery +destinations. Everything outside them keeps the default answer, so this only +ever adds destinations — it never removes any, and it cannot narrow what was +already reachable. -Listing `0.0.0.0/0` or `::/0` opens **every** other private and -reserved range at once — loopback, RFC 1918, CGNAT, ULA, the lot. It is -a functional off switch for everything except the addresses listed as -unconditionally blocked below, and it makes any delivery target a probe -into your entire network and this host's own loopback services. Do not -list it. +**The risk, plainly.** Each block you list is a network that anyone who can +create a delivery target can now make this process issue requests into, and read +the response body back out of via the delivery log. That is server-side request +forgery, deliberately enabled and scoped by you. A webhooker admin account is +therefore as trusted as the narrowest thing on those networks: an +unauthenticated admin panel, a database listening without a password, or an +internal API that trusts its network position is reachable through it. List the +smallest blocks that cover the destinations you actually deliver to — prefer +`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the network +you are willing to expose. + +Listing `0.0.0.0/0` or `::/0` opens **every** other private and reserved range +at once — loopback, RFC 1918, CGNAT, ULA, the lot. It is a functional off switch +for everything except the addresses listed as unconditionally blocked below, and +it makes any delivery target a probe into your entire network and this host's +own loopback services. Do not list it. Two things this setting cannot do: -- **It cannot turn the guard off.** There is no boolean, and no value - that disables SSRF protection wholesale. The guard is always on and - the list is always an allowlist; an empty list (the default) means - every private and reserved range stays refused. Note that - `0.0.0.0/0` gets you most of the way there anyway, per above. -- **It cannot open link-local, or a cloud metadata endpoint at a - non-public address that discloses credentials or user data.** An - address is on the list below when it is not a public address and both - of these hold: the provider fixes it, so it cannot collide with - anything you run; and reaching it hands out credentials, user data or - bootstrap material. Those stay blocked no matter what you list, - including when you list them outright or list a supernet such as - `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best - effort rather than a guarantee — it is a hand-maintained list and the - caveat below the table applies: +- **It cannot turn the guard off.** There is no boolean, and no value that + disables SSRF protection wholesale. The guard is always on and the list is + always an allowlist; an empty list (the default) means every private and + reserved range stays refused. Note that `0.0.0.0/0` gets you most of the way + there anyway, per above. +- **It cannot open link-local, the unspecified addresses, or a cloud metadata + endpoint at a non-public address that discloses credentials or user data.** A + metadata address is on the list below when it is not a public address and both + of these hold: the provider fixes it, so it cannot collide with anything you + run; and reaching it hands out credentials, user data or bootstrap material. + Those stay blocked no matter what you list, including when you list them + outright or list a supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or + `100.64.0.0/10`. Treat this as best effort rather than a guarantee — it is a + hand-maintained list and the caveat below the table applies: - | Blocked unconditionally | What it is | - | ----------------------- | ---------- | - | `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, Azure, DigitalOcean, Hetzner, OpenStack and others — not Alibaba, which uses `100.100.100.200` below) | - | `fe80::/10` | IPv6 link-local | - | `fd00:ec2::254/128` | AWS IPv6 IMDS | - | `fd00:ec2::23/128` | AWS EKS Pod Identity Agent | - | `fd20:ce::254/128` | GCP metadata for IPv6-only instances | - | `fd00:c1::a9fe:a9fe/128` | Oracle OCI IMDS over IPv6 | - | `fd00:42::42/128` | Scaleway metadata over IPv6 | - | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | - | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | - | `192.0.0.192/32` | Oracle Cloud Classic metadata | - | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | - | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | + | Blocked unconditionally | What it is | + | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, Azure, DigitalOcean, Hetzner, OpenStack and others — not Alibaba, which uses `100.100.100.200` below) | + | `fe80::/10` | IPv6 link-local | + | `fd00:ec2::254/128` | AWS IPv6 IMDS | + | `fd00:ec2::23/128` | AWS EKS Pod Identity Agent | + | `fd20:ce::254/128` | GCP metadata for IPv6-only instances | + | `fd00:c1::a9fe:a9fe/128` | Oracle OCI IMDS over IPv6 | + | `fd00:42::42/128` | Scaleway metadata over IPv6 | + | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | + | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | + | `192.0.0.192/32` | Oracle Cloud Classic metadata | + | `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux | + | `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux | + | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | + | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | - The IPv4-mapped form `::ffff:169.254.169.254` is covered by the - `169.254.0.0/16` entry. Reaching any of these is credential or - user-data theft rather than delivery to an internal service. Every - entry outside the two link-local blocks is a single address, so - blocking it costs you nothing else on the network around it. + The IPv4-mapped form `::ffff:169.254.169.254` is covered by the + `169.254.0.0/16` entry. Reaching any of these but the two unspecified + addresses is credential or user-data theft rather than delivery to an + internal service. Every entry outside the two link-local blocks is a single + address, so blocking it costs you nothing else on the network around it. - The six ULA entries, all inside `fd00::/8`, are why this matters in - practice: `fd00::/8` is an ordinary block to allowlist for your own - IPv6 network, and without those host routes that one line would hand - out cloud credentials on five providers at once. There is only one - `/8` involved — `fd20:ce::254` masks into `fd00::/8` as well — and - the six endpoints are five providers because AWS appears twice, IMDS - and EKS Pod Identity. Several of them are described as "link-local" — - or even "localhost" — in their own vendor's documentation, but they - are ULAs and `fe80::/10` does not cover them. + The unspecified addresses `0.0.0.0` and `::` hand out nothing themselves, + but no host can have either, and on Linux a connection to one reaches this + host's own loopback. They are listed so that an allowlist reaches loopback + only through an entry that covers a loopback address, such as `127.0.0.0/8`, + `::1` or `0.0.0.0/0`, never through one that covers only `0.0.0.0` or `::`; + `0.0.0.0/8`, for example, does not open loopback. - Every entry above is reserved space. All but the last two are already - refused with no allowlist set, and listing them here is only what - stops an allowlist from reopening them; the last two are the alternate - encodings, which the default blocklist does not match. A publicly - routable metadata address is not listed here, because nothing on this - list can be reopened and blocking one that way would leave you no - escape hatch at all; Azure's `168.63.129.16` is refused by the default - blocklist instead, as described above. + The six ULA entries, all inside `fd00::/8`, are why this matters in + practice: `fd00::/8` is an ordinary block to allowlist for your own IPv6 + network, and without those host routes that one line would hand out cloud + credentials on five providers at once. There is only one `/8` involved — + `fd20:ce::254` masks into `fd00::/8` as well — and the six endpoints are + five providers because AWS appears twice, IMDS and EKS Pod Identity. Several + of them are described as "link-local" — or even "localhost" — in their own + vendor's documentation, but they are ULAs and `fe80::/10` does not cover + them. - This list is not exhaustive of every cloud's metadata address — if - yours is not here, do not allowlist the block that contains it. + Every entry above is reserved space. All but the last two are already + refused with no allowlist set, and listing them here is only what stops an + allowlist from reopening them; the last two are the alternate encodings, + which the default blocklist does not match. A publicly routable metadata + address is not listed here, because nothing on this list can be reopened and + blocking one that way would leave you no escape hatch at all; Azure's + `168.63.129.16` is refused by the default blocklist instead, as described + above. -The list is applied at one place in the code, which both target -creation and delivery consult, so a URL that the target form accepts is -one that delivery will actually attempt — the two cannot disagree. -Delivery re-resolves and re-checks the destination at dial time, so a -hostname that resolves to an allowed address during validation and a -different one later (DNS rebinding) is still refused unless the new -address is also allowed. + This list is not exhaustive of every cloud's metadata address — if yours is + not here, do not allowlist the block that contains it. -A set but unparseable value aborts startup. When the list is non-empty -webhooker logs it at startup, blocks and all, so the hole is visible in -the log of any deployment that has one. +The list is applied at one place in the code, which both target creation and +delivery consult, so a URL that the target form accepts is one that delivery +will actually attempt — the two cannot disagree. Delivery re-resolves and +re-checks the destination at dial time, so a hostname that resolves to an +allowed address during validation and a different one later (DNS rebinding) is +still refused unless the new address is also allowed. + +A set but unparseable value aborts startup. When the list is non-empty webhooker +logs it at startup, blocks and all, so the hole is visible in the log of any +deployment that has one. #### Bind address -`BIND_ADDRESS` is the IP address the HTTP listener binds. The binary -defaults to `127.0.0.1`, so a bare webhooker is reachable only from the -host it runs on. The Docker image ships `ENV BIND_ADDRESS=0.0.0.0` -instead — see below for why the two differ. +`BIND_ADDRESS` is the IP address the HTTP listener binds. The binary defaults to +`127.0.0.1`, so a bare webhooker is reachable only from the host it runs on. The +Docker image ships `ENV BIND_ADDRESS=0.0.0.0` instead — see below for why the +two differ. -That listener speaks **cleartext**, and it serves both the admin UI and -the unauthenticated webhook receiver. webhooker terminates no TLS -itself; a production deployment puts a reverse proxy in front of it -(see -[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)), -and the proxy reaches it over loopback. A default that bound every -interface would leave that cleartext port answering the internet -alongside the proxy — the admin login form and the receiver, in the -clear, on a port nobody chose to publish. Reaching webhooker from -another host is therefore something you configure, not something you -get by default. +That listener speaks **cleartext**, and it serves both the admin UI and the +unauthenticated webhook receiver. webhooker terminates no TLS itself; a +production deployment puts a reverse proxy in front of it (see +[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)), and +the proxy reaches it over loopback. A default that bound every interface would +leave that cleartext port answering the internet alongside the proxy — the admin +sign-in form and the receiver, in the clear, on a port nobody chose to publish. +Reaching webhooker from another host is therefore something you configure, not +something you get by default. -**In a container the answer is `0.0.0.0`, which is why the image ships -that.** A container's network namespace is already the boundary the -loopback default is reaching for: nothing outside the container gets to -`0.0.0.0:8080` because of the namespace, whatever the process bound. -Exposure is decided at the publish flag instead — `-p -127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is the -operator's to choose and is what -[Running with Docker](#running-with-docker) shows. A loopback bind -inside a container buys nothing and makes the process unreachable -through its own published port. +**In a container the answer is `0.0.0.0`, which is why the image ships that.** A +container's network namespace is already the boundary the loopback default is +reaching for: nothing outside the container gets to `0.0.0.0:8080` because of +the namespace, whatever the process bound. Exposure is decided at the publish +flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is +the operator's to choose and is what [Running with Docker](#running-with-docker) +shows. A loopback bind inside a container buys nothing and makes the process +unreachable through its own published port. The value must be an IP address literal: -- `127.0.0.1` — loopback only (the binary's default). Use this with a - reverse proxy on the same host. -- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, - only behind a firewall on the port. +- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse + proxy on the same host. +- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only + behind a firewall on the port. - `::` — every address, IPv6 and (on Linux, with the default `net.ipv6.bindv6only=0`) IPv4 as well. - A specific address such as `10.0.0.5` — that interface only. -An **empty** value is treated as unset, as everywhere else here, and -takes the default. In a container that matters: `BIND_ADDRESS=` throws -away the image's `0.0.0.0` and falls back to the binary's -`127.0.0.1`, which is the one quiet failure this setting has — see -[Running with Docker](#running-with-docker). +An **empty** value is treated as unset, as everywhere else here, and takes the +default. In a container that matters: `BIND_ADDRESS=` throws away the image's +`0.0.0.0` and falls back to the binary's `127.0.0.1`, which is the one quiet +failure this setting has — see [Running with Docker](#running-with-docker). -Hostnames are **not** accepted. `localhost` aborts startup rather than -being resolved: which of `127.0.0.1` and `::1` it means differs by -host, a name can resolve to several addresses of which only one could -be bound, and the answer can change under a running process. A value -carrying a port (`127.0.0.1:8080`) is likewise rejected — the port is -`PORT`'s business. Any unparseable value aborts startup; see +Hostnames are **not** accepted. `localhost` aborts startup rather than being +resolved: which of `127.0.0.1` and `::1` it means differs by host, a name can +resolve to several addresses of which only one could be bound, and the answer +can change under a running process. A value carrying a port (`127.0.0.1:8080`) +is likewise rejected — the port is `PORT`'s business. Any unparseable value +aborts startup; see [Invalid values abort startup](#invalid-values-abort-startup). -An address that parses but is not assigned to this host — say -`10.0.0.5` on a machine that has no such interface — is a valid -literal, so it reaches the listener and fails there. The process logs -the bind error and exits non-zero rather than staying up with nothing -listening. The effective value is in the `bindAddress` field of the -startup log line, which is the way to check what a running deployment -actually bound. +An address that parses but is not assigned to this host — say `10.0.0.5` on a +machine that has no such interface — is a valid literal, so it reaches the +listener and fails there. The process logs the bind error and exits non-zero +rather than staying up with nothing listening. The effective value is in the +`bindAddress` field of the startup log line, which is the way to check what a +running deployment actually bound. #### Metrics credentials -`METRICS_USERNAME` and `METRICS_PASSWORD` are set together or not at -all. With both set, `/metrics` is served behind basic auth. With -neither set, the route is not registered and returns 404. With one set -and the other empty or unset, the process refuses to start and exits -non-zero with an error naming both variables — mounting the endpoint -on the username alone would publish it behind a password that is the -empty string, and quietly withholding it would deny an endpoint that -was asked for. The `hasMetricsAuth` field in the startup log and the -existence of the route are the same value, so they cannot disagree. +`METRICS_USERNAME` and `METRICS_PASSWORD` are set together or not at all. With +both set, `/metrics` is served behind basic auth. With neither set, the route is +not registered and returns 404. With one set and the other empty or unset, the +process refuses to start and exits non-zero with an error naming both variables +— mounting the endpoint on the username alone would publish it behind a password +that is the empty string, and quietly withholding it would deny an endpoint that +was asked for. The `hasMetricsAuth` field in the startup log and the existence +of the route are the same value, so they cannot disagree. #### Single-instance lock -Exactly one webhooker process may use a `DATA_DIR` at a time. Two -processes sharing one open the same databases and each run delivery -recovery over the same rows, so every pending delivery goes out twice — -duplicate delivery to your endpoints, from nothing worse than an -overlapping deploy or a double start. +Exactly one webhooker process may use a `DATA_DIR` at a time. Two processes +sharing one open the same databases and each run delivery recovery over the same +rows, so every pending delivery goes out twice — duplicate delivery to your +endpoints, from nothing worse than an overlapping deploy or a double start. -At startup, before anything opens a database, the process takes an -exclusive advisory lock (`flock(2)`) on `{DATA_DIR}/webhooker.lock` and -holds it for its lifetime. A second process pointed at the same -directory prints a message naming it and exits non-zero: +At startup, before anything opens a database, the process takes an exclusive +advisory lock (`flock(2)`) on `{DATA_DIR}/webhooker.lock` and holds it for its +lifetime. A second process pointed at the same directory prints a message naming +it and exits non-zero: ``` webhooker: data directory is already in use by another instance: /var/lib/webhooker (/var/lib/webhooker/webhooker.lock). Only one webhooker may use a data directory: two both run delivery recovery over the same rows and both deliver ``` -The lock is the kernel's, not the file's: it is released when the -process exits, including `kill -9`, so a leftover `webhooker.lock` -never blocks a restart and must not be deleted by hand. The file is -also left in place on a clean shutdown, deliberately — unlinking it -would let the next process lock a fresh inode while a third still held -the old one. +The lock is the kernel's, not the file's: it is released when the process exits, +including `kill -9`, so a leftover `webhooker.lock` never blocks a restart and +must not be deleted by hand. The file is also left in place on a clean shutdown, +deliberately — unlinking it would let the next process lock a fresh inode while +a third still held the old one. To run two webhookers on one host, give each its own `DATA_DIR`. -`flock(2)` is host-local and per-inode: it arbitrates between processes -and containers sharing a volume or bind mount on one machine, but not -between hosts on a network filesystem, and a `DATA_DIR` inside a -container's own writable layer is not shared with anything. On a -filesystem that refuses `flock` outright, startup fails closed — the -process reports the error and refuses to start rather than running -unlocked. +`flock(2)` is host-local and per-inode: it arbitrates between processes and +containers sharing a volume or bind mount on one machine, but not between hosts +on a network filesystem, and a `DATA_DIR` inside a container's own writable +layer is not shared with anything. On a filesystem that refuses `flock` +outright, startup fails closed — the process reports the error and refuses to +start rather than running unlocked. #### Trusted proxies -`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare -address such as `192.168.1.7` is accepted and treated as a single -host), for example `192.168.1.7, 2001:db8::5`. It decides whose -`X-Forwarded-For` header the rate limiters believe, so it should name -the addresses of your reverse proxies and nothing else. +`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare address such +as `192.168.1.7` is accepted and treated as a single host), for example +`192.168.1.7, 2001:db8::5`. It decides whose `X-Forwarded-For` header the rate +limiters believe, so it should cover the addresses of your reverse proxies. -`X-Forwarded-For` is honoured **only** when the connecting peer is -inside one of these blocks; for every other peer the client identity is -the connection's own address and the header is ignored. The default is -the empty list, which trusts nobody — anything else would let any -client pick its own rate limit bucket, minting a fresh one per request -or draining someone else's. Set it to the address of your reverse -proxy, and to nothing wider. A set but unparseable value aborts -startup. +`X-Forwarded-For` is honoured **only** when the connecting peer is inside one of +these blocks; for every other peer the client identity is the connection's own +address and the header is ignored. Unset (or empty), the list is the RFC 1918 +private ranges: `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`. A set value +replaces the default entirely. A set but unparseable value aborts startup. -That default is safe against forged headers, but leaving it unset in -production has a cost you must know about. Production runs behind a -TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every -request keys on the proxy's own address and all clients share a single -bucket per limit. The receiver limits become service-wide ceilings, -and the login endpoint's failure counting collapses onto one key, so a -stranger's wrong passwords throttle every other client's wrong -passwords. +If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 +source address (directly, or through anything that can rewrite source addresses, +such as NAT or a published container port), set `TRUSTED_PROXIES` to the proxy's +address alone, or every rate limit, the webhook receiver's included, can be +bypassed by those clients. The address to set is the `remoteIP` field of the +`http request` log line for a request that came through the proxy. -What it cannot do is lock the operator out. The login endpoint -verifies credentials **before** it consults any limit and charges only -failures, so a correct password is never throttled no matter how full -the bucket is. See [Rate Limiting](#rate-limiting). +Behind a proxy the list does not cover, every request keys on the proxy's own +address and all clients share a single bucket per limit. The receiver limits +become service-wide ceilings, and the login endpoint's failure counting +collapses onto one key, so a stranger's wrong passwords throttle every other +client's wrong passwords. Set `TRUSTED_PROXIES` to that proxy's address to +restore per-client buckets. -The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's -address, which restores per-client buckets. webhooker logs a warning -at startup whenever `TRUSTED_PROXIES` is empty, in every environment, -because behind a proxy every client shares one bucket in `dev` and -`prod` alike. The warning is informational when nothing proxies to the -process: with no proxy in front, the peer address is the client's own -and the buckets are already per-client. See -[Rate Limiting](#rate-limiting) for what each limit shares. +What it cannot do is lock the operator out. The login endpoint verifies +credentials **before** it consults any limit and charges only failures, so a +correct password is never throttled no matter how full the bucket is. See +[Rate Limiting](#rate-limiting). -`X-Real-IP` and `True-Client-IP` are **never** read, from any peer. -Reverse proxies append to `X-Forwarded-For` but forward other client -headers verbatim, so a single-valued header is client-controlled even -behind a trusted proxy. +`X-Real-IP` and `True-Client-IP` are **never** read, from any peer. Reverse +proxies append to `X-Forwarded-For` but forward other client headers verbatim, +so a single-valued header is client-controlled even behind a trusted proxy. -Within a trusted request, `X-Forwarded-For` is read right to left, -because the rightmost entry is the one the nearest proxy appended and -everything left of it may have been written by the client. The first -hop that is not itself a trusted proxy is taken as the client. A hop -that is not a bare IP address — `ip:port`, a bracketed IPv6 literal, -the token `unknown` — ends the walk and the peer address is used -instead, since past such an entry the chain is not the shape assumed -here. The peer address is likewise used when the header is absent or -every hop in it is a trusted proxy. +Within a trusted request, `X-Forwarded-For` is read right to left, because the +rightmost entry is the one the nearest proxy appended and everything left of it +may have been written by the client. The first hop that is not itself a trusted +proxy is taken as the client. A hop that is not a bare IP address — `ip:port`, a +bracketed IPv6 literal, the token `unknown` — ends the walk and the peer address +is used instead, since past such an entry the chain is not the shape assumed +here. The peer address is likewise used when the header is absent or every hop +in it is a trusted proxy. -Two operator requirements follow: +Your proxy must therefore **append** the peer address to `X-Forwarded-For` +(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`, Caddy and AWS +ALB by default), and must append a bare address with no port. -- Your proxy must **append** the peer address to `X-Forwarded-For` - (nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`, - Caddy and AWS ALB by default), and must append a bare address with - no port. -- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES` - chooses its own rate-limit key: its `X-Forwarded-For` is walked, so - it can name a different address on every request to get a fresh - bucket each time, or name another client's address to drain that - client's bucket. Never list a block that also covers clients — a - broad `10.0.0.0/8` on a network where clients live in the same range - makes all three limits, including the unauthenticated webhook - receiver, silently bypassable by every client in the block. +Every log line that names a client carries two addresses: `remoteIP`, the +connecting peer, which behind a proxy is the proxy; and `clientIP`, the client +the rate limiters identify by the rules above, which is the field to read when +tracing who sent what. Those lines are the `http request` access log line, the +rate-limit rejection lines (`login failure limit exceeded` among them), the +`csrf: token validation failed` warning and the receiver's +`webhook request received` line. `clientIP` is only as trustworthy as +`TRUSTED_PROXIES`: for a request from a peer inside the list, it is read out of +the `X-Forwarded-For` that peer sent, so a peer that does not belong in the list +can make it name any address it likes. For a request from any other peer, both +fields name the peer. #### Sessions -Sessions are bounded by two independent clocks, and end at whichever -one runs out first: +Sessions are bounded by two independent clocks, and end at whichever one runs +out first: -- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding - window. Every authenticated request pushes it forward, so a session - in continuous use never hits it, while an abandoned one expires a day - after its last use. Any non-positive value (`0`, or a negative - duration such as `-1s`) disables idle expiry entirely; the absolute - cap below still applies. A set-but-unparseable value aborts startup - rather than silently falling back to the default. -- **Absolute expiry** is a fixed 7 days from login. Activity does - **not** extend it: after a week, every session ends and the user - authenticates again. +- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding window. + Every authenticated request pushes it forward, so a session in continuous use + never hits it, while an abandoned one expires a day after its last use. Any + non-positive value (`0`, or a negative duration such as `-1s`) disables idle + expiry entirely; the absolute cap below still applies. A set-but-unparseable + value aborts startup rather than silently falling back to the default. +- **Absolute expiry** is a fixed 7 days from login. Activity does **not** extend + it: after a week, every session ends and the user authenticates again. -Only requests that authenticate with the session count as activity, so -an unauthenticated request carrying the cookie cannot keep a session -alive. The idle timestamp is rewritten at most once per tenth of the -idle window rather than on every request, which means a session may -expire up to 10% early relative to the user's true last request, but -never late. +Only requests that authenticate with the session count as activity, so an +unauthenticated request carrying the cookie cannot keep a session alive. The +idle timestamp is rewritten at most once per tenth of the idle window rather +than on every request, which means a session may expire up to 10% early relative +to the user's true last request, but never late. Both clocks are anchored by timestamps stored in the session cookie. -Sessions issued before this feature existed carry neither, so they are -treated as expired: upgrading to a build that has it logs every -existing session out once, and those users sign in again. #### Invalid values abort startup -The defaults above apply **only** to variables that are unset (or set -to an empty string). A variable that is set but cannot be parsed is a -fatal configuration error: webhooker logs the offending variable and -its value and refuses to start, rather than silently running with a -substituted default. `PORT=eighty`, `DEBUG=ture`, and -`RETENTION_SWEEP_INTERVAL=1 hour` all abort startup. `PORT` must -additionally be a number in the range 1–65535, -`RECEIVER_RATE_LIMIT` must be at least 1, -`RETENTION_SWEEP_INTERVAL` must be greater than zero (it is a ticker -period, so `0s` or a negative value would crash the reaper after -startup), every entry in `TRUSTED_PROXIES` and +The defaults above apply **only** to variables that are unset (or set to an +empty string). A variable that is set but cannot be parsed is a fatal +configuration error: webhooker logs the offending variable and its value and +refuses to start, rather than silently running with a substituted default. +`PORT=eighty`, `DEBUG=ture`, and `RETENTION_SWEEP_INTERVAL=1 hour` all abort +startup. `PORT` must additionally be a number in the range 1–65535, +`RECEIVER_RATE_LIMIT` must be at least 1, `RETENTION_SWEEP_INTERVAL` must be +greater than zero (it is a ticker period, so `0s` or a negative value would +crash the reaper after startup), every entry in `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and -`BIND_ADDRESS` must be an IP address literal — `localhost`, -`127.0.0.1:8080` and `10.0.0.0/8` are each rejected rather than -resolved, split, or narrowed to something they do not say — and -`SENTRY_DSN` must parse as a Sentry DSN. -`SESSION_IDLE_TIMEOUT` is the exception: a -non-positive value there means idle expiry is disabled, not invalid. +`BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and +`10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to +something they do not say — `SENTRY_DSN` must parse as a Sentry DSN, and +`HSTS_MAX_AGE` must be a whole number of seconds, zero or more (`1.5s` and `-1h` +are rejected). `SESSION_IDLE_TIMEOUT` is the exception: a non-positive value +there means idle expiry is disabled, not invalid. -`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call -the SDK makes on the DSN it is later handed, so what configuration -accepts is exactly what will initialise. A typo in it is the one -configuration mistake nothing downstream can ever notice — the variable -is still set, so every later signal reports error reporting as on while -no report is being sent — which is why it aborts rather than starting -with reporting off. Leaving it unset is not a mistake and not affected: -error reporting is simply off and startup is normal. +`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK +makes on the DSN it is later handed, so what configuration accepts is exactly +what will initialise. A typo in it is the one configuration mistake nothing +downstream can ever notice — the variable is still set, so every later signal +reports error reporting as on while no report is being sent — which is why it +aborts rather than starting with reporting off. Leaving it unset is not a +mistake and not affected: error reporting is simply off and startup is normal. -Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the -spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, -`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing -else. `yes`, `on`, and `off` are rejected rather than quietly treated -as false. +The boolean variable `DEBUG` accepts exactly the spellings Go's +`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, +`F`, `FALSE`, `false`, `False` — and nothing else. `yes`, `on`, and `off` are +rejected rather than quietly treated as false. -On first startup, webhooker automatically generates a cryptographically -secure session encryption key and stores it in the database. This key -persists across restarts — no manual key management is needed. +On first startup, webhooker automatically generates a cryptographically secure +session encryption key and stores it in the database. This key persists across +restarts — no manual key management is needed. #### The admin account -On first startup — a `DATA_DIR` with no accounts in it — webhooker -creates an `admin` user with a randomly generated password and prints -it to standard output as a ruled banner: +On first startup — a `DATA_DIR` with no accounts in it — webhooker creates an +`admin` user with a randomly generated password and prints it to standard output +as a ruled banner: ``` ======================================================================== @@ -530,24 +521,23 @@ If it is lost, run `webhooker resetpw admin` on a stopped deployment. ======================================================================== ``` -It is a banner rather than a log line because that is the only time it -is ever shown: as one `INFO` record it sat among the roughly 45 fx -`PROVIDE`/`RUN`/`HOOK` lines a boot writes, and under `docker run -d` -it is one line in a log subject to rotation. The database stores only -its Argon2id hash. There is no second account and no forgot-password -flow, so the banner and the reset command below are the only two ways -in. +It is a banner rather than a log line because that is the only time it is ever +shown: as one `INFO` record it would sit among the records fx writes as each +start hook runs, and under `docker run -d` it is one line in a log subject to +rotation. The database stores only its Argon2id hash. There is no second account +and no forgot-password flow, so the banner and the reset command below are the +only two ways in. -A start that finds no `webhooker.db` in `DATA_DIR` also logs -`created a new, empty database` at `WARN`, with the file's path, -shortly before the banner. On a deployment that has run before, that -line means `DATA_DIR` was empty, most often because its volume is not -mounted. +A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length one (which +SQLite opens as an empty database), also logs `created a new, empty database` at +`WARN`, with the file's path, shortly before the banner. On a deployment that +has run before, that line means `webhooker.db` was lost: either the file was +missing, most often because the volume holding `DATA_DIR` is not mounted, or it +was zero-length, as a truncated copy leaves it. #### Recovering a lost admin password -`webhooker resetpw` sets an existing account's password from the -command line: +`webhooker resetpw` sets an existing account's password from the command line: ```bash # Generate a new password and print it. @@ -558,93 +548,88 @@ printf '%s' "$NEW_PASSWORD" | \ DATA_DIR=/var/lib/webhooker webhooker resetpw admin ``` -In a container it is the same binary. The image's `CMD` is -`/app/webhooker`, and a command given to `docker run` replaces all of -it, so the whole command has to be given: +In a container it is the same binary. The image's `CMD` is `/app/webhooker`, and +a command given to `docker run` replaces all of it, so the whole command has to +be given: ```bash docker run --rm -v webhooker-data:/var/lib/webhooker \ webhooker /app/webhooker resetpw -generate admin ``` -Stop the service first — with the volume still attached to a running -container, the command refuses. +Stop the service first — with the volume still attached to a running container, +the command refuses. -The password is never taken as a command-line argument: on Linux argv -is readable through `/proc` by every account on the host for as long as -the process lives. Standard input is echoed when it is a terminal — the -prompt says so — so `-generate` or a pipe is preferable on a shared -machine. +The password is never taken as a command-line argument: on Linux argv is +readable through `/proc` by every account on the host for as long as the process +lives. Standard input is echoed when it is a terminal — the prompt says so — so +`-generate` or a pipe is preferable on a shared machine. What it will not do: -- **Run against a live deployment.** It takes the same exclusive - `DATA_DIR` lock the server does (see - [Single-instance lock](#single-instance-lock)) and refuses while a - running instance holds it, naming the directory and exiting non-zero. - A running process keeps serving every session that authenticated with - the old password, so a reset underneath it would report a change the +- **Run against a live deployment.** It takes the same exclusive `DATA_DIR` lock + the server does (see [Single-instance lock](#single-instance-lock)) and + refuses while a running instance holds it, naming the directory and exiting + non-zero. A running process keeps serving every session that authenticated + with the old password, so a reset underneath it would report a change the service does not honour. -- **Create anything.** A `DATA_DIR` that does not exist, or that holds - no `webhooker.db`, is an error rather than a new empty deployment — - a mistyped path must not be built out and then reported as a success. -- **Create an account.** A username that does not exist is an error. - `resetpw` changes an existing account's password and nothing else. +- **Create anything.** A `DATA_DIR` that does not exist, or that holds no + `webhooker.db` or a zero-length one, is an error naming the path rather than a + new empty deployment — a mistyped path must not be built out and then reported + as a success. +- **Create an account.** A username that does not exist is an error. `resetpw` + changes an existing account's password and nothing else. -`DATA_DIR` selects the deployment exactly as it does for the server. A -password that changes on disk takes effect at the next login; sessions -that are already authenticated are unaffected either way. +`DATA_DIR` selects the deployment exactly as it does for the server. A password +that changes on disk takes effect at the next login; sessions that are already +authenticated are unaffected either way. Changing a password you still know needs none of this — use `POST /user/{username}/password` in the web UI. #### What `DEBUG=true` exposes -`DEBUG=true` lowers the log level to `DEBUG`, which turns on every -statement GORM runs, the two by-design lookup misses on the -unauthenticated routes, and the rate limiter's own rejections. It is -meant to be safe to turn on while diagnosing a live service and safe to -paste the output of into a bug report. +`DEBUG=true` lowers the log level to `DEBUG`, which turns on every statement +GORM runs, the two by-design lookup misses on the unauthenticated routes, the +rate limiter's own rejections, and fx's records of building the dependency graph +at startup. It is meant to be safe to turn on while diagnosing a live service +and safe to paste the output of into a bug report. What it does **not** put in the log: - **Values bound to a SQL statement.** Statements are logged with their - placeholders, never with the values substituted into them, at every - level. That is what keeps the session encryption key out of the first - boot's `INSERT INTO settings` and the `admin` account's Argon2id - password hash out of its `INSERT INTO users` — the two statements - that made a debug log worth stealing. It applies to every table and - every statement rather than to a list of tables known to hold a - secret, so a table added later is covered without anyone remembering - to add it. The cost is that a failing statement can no longer be - replayed from the log alone: the statement, the table, the driver - error and the row count are all still there, but its values have to - come from the database. - `internal/gormlog/firstboot_test.go` boots the real graph with - `DEBUG=true` against an empty `DATA_DIR` and asserts that neither - secret appears in what that boot wrote to stdout. - The one exception is `(*gorm.DB).Scan`, which GORM logs through its - own trace recorder rather than through this filter. No production - code path calls it, and `internal/gormlog/scan_guard_test.go` fails - if a non-test file adds one. -- **Session cookies, API keys or target credentials.** None of these is - logged at any level. + placeholders, never with the values substituted into them, at every level. + That is what keeps the session encryption key out of the first boot's + `INSERT INTO settings` and the `admin` account's Argon2id password hash out of + its `INSERT INTO users` — the two statements that made a debug log worth + stealing. It applies to every table and every statement rather than to a list + of tables known to hold a secret, so a table added later is covered without + anyone remembering to add it. The cost is that a failing statement can no + longer be replayed from the log alone: the statement, the table, the driver + error and the row count are all still there, but its values have to come from + the database. `internal/gormlog/firstboot_test.go` boots the real graph with + `DEBUG=true` against an empty `DATA_DIR` and asserts that neither secret + appears in what that boot wrote to stdout. The one exception is + `(*gorm.DB).Scan`, which GORM logs through its own trace recorder rather than + through this filter. No production code path calls it, and + `internal/gormlog/scan_guard_test.go` fails if a non-test file adds one. +- **Session cookies, API keys or target credentials.** None of these is logged + at any level. -What is in the log regardless of `DEBUG`, and is not a debug-logging -decision: +What is in the log regardless of `DEBUG`, and is not a debug-logging decision: -- **The initial `admin` password**, in the clear, once, on the first - boot that creates the account — as the banner described under - [The admin account](#the-admin-account), written straight to standard - output rather than through the logger. That banner is the only place - it is ever shown; the database stores the hash. A first boot's output - is not safe to paste anywhere until that account's password has been - changed. The same applies to `webhooker resetpw -generate`, which - prints the password it generated in the same form. -- **An authenticated operator's own configuration**, echoed back - untruncated — webhook names, target hostnames. See the logging - section under Security for the full list and for the per-line size - bound that covers unauthenticated traffic. +- **The initial `admin` password**, in the clear, once, on the first boot that + creates the account — as the banner described under + [The admin account](#the-admin-account), written straight to standard output + rather than through the logger. That banner is the only place it is ever + shown; the database stores the hash. A first boot's output is not safe to + paste anywhere until that account's password has been changed. The same + applies to `webhooker resetpw -generate`, which prints the password it + generated in the same form. +- **An authenticated operator's own configuration**, echoed back untruncated — + webhook names, target hostnames. See the logging section under Security for + the full list and for the per-line size bound that covers unauthenticated + traffic. ### Running with Docker @@ -661,110 +646,106 @@ purpose.** The binary defaults to `127.0.0.1`; the image ships `ENV BIND_ADDRESS=0.0.0.0`, so the `-e BIND_ADDRESS=0.0.0.0` above is belt-and-braces and the command works without it. -The two cases are not the same question. On a bare host, `0.0.0.0` -puts the cleartext admin UI and the unauthenticated receiver on every -interface of the machine, which is what the loopback default exists to -prevent. In a container, the network namespace is already that -boundary: nothing outside reaches `0.0.0.0:8080` because of the -namespace, not because of the bind. What decides exposure there is the -**publish flag**, and that is the line to get right. +The two cases are not the same question. On a bare host, `0.0.0.0` puts the +cleartext admin UI and the unauthenticated receiver on every interface of the +machine, which is what the loopback default exists to prevent. In a container, +the network namespace is already that boundary: nothing outside reaches +`0.0.0.0:8080` because of the namespace, not because of the bind. What decides +exposure there is the **publish flag**, and that is the line to get right. -So publish to `127.0.0.1:8080` rather than `8080`. A bare -`-p 8080:8080` opens the port on every interface of the host — through -firewall rules too, since Docker's forwarding rules are inserted ahead -of most host firewalls. Publish to the host address your reverse proxy -connects from, and nothing wider. +So publish to `127.0.0.1:8080` rather than `8080`. A bare `-p 8080:8080` opens +the port on every interface of the host — through firewall rules too, since +Docker's forwarding rules are inserted ahead of most host firewalls. Publish to +the host address your reverse proxy connects from, and nothing wider. -**An empty `BIND_ADDRESS` is treated as unset**, like every other -variable here, so `-e BIND_ADDRESS=` does not mean "keep the image -default" — it discards the image's `0.0.0.0` and falls back to the -_binary's_ `127.0.0.1`. In a container that is the failure below, and -nothing in the logs names the variable. A templated Compose file or a -`.env` line with an empty value is the usual way in. Either set a -literal or leave the variable out entirely. +**An empty `BIND_ADDRESS` is treated as unset**, like every other variable here, +so `-e BIND_ADDRESS=` does not mean "keep the image default" — it discards the +image's `0.0.0.0` and falls back to the _binary's_ `127.0.0.1`. In a container +that is the failure below, and nothing in the logs names the variable. A +templated Compose file or a `.env` line with an empty value is the usual way in. +Either set a literal or leave the variable out entirely. -Overriding `BIND_ADDRESS` to a loopback address in a container — by -that route or deliberately — makes the container unreachable from -outside its namespace even with `-p`. The published port answers -nothing, and the health check fails too: it requests -`http://localhost:8080`, `localhost` resolves to `::1` first, and a -`127.0.0.1` bind is not listening there. The container then goes -`unhealthy` about **65 seconds** after start — from `HEALTHCHECK ---start-period=5s --interval=30s --retries=3`, so failing probes at -5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence -during the start period has changed between versions; re-derive from -those three values rather than trusting the figure. Measured at 65s on -Docker 29.7.2.) A container `unhealthy` with `connection refused` in -its health log, or a published port that resets connections, is this. +Overriding `BIND_ADDRESS` to a loopback address in a container — by that route +or deliberately — makes the container unreachable from outside its namespace +even with `-p`. The published port answers nothing, and the health check fails +too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first, +and a `127.0.0.1` bind is not listening there. The container then goes +`unhealthy` about **65 seconds** after start — from +`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at +5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during +the start period has changed between versions; re-derive from those three values +rather than trusting the figure. Measured at 65s on Docker 29.7.2.) A container +`unhealthy` with `connection refused` in its health log, or a published port +that resets connections, is this. -The app runs as a non-root user (`webhooker`, UID 1000), exposes port -8080, and includes a health check against `/.well-known/healthcheck`. -The `/var/lib/webhooker` volume holds all SQLite databases: the main -application database (`webhooker.db`), the per-webhook event databases -(`events-{uuid}.db`), and any archive databases written by `database` -targets (`archive-{uuid}.db`). Mount this as a persistent volume to +The app runs as a non-root user (`webhooker`, UID 1000), exposes port 8080, and +includes a health check against `/.well-known/healthcheck`. The +`/var/lib/webhooker` volume holds all SQLite databases: the main application +database (`webhooker.db`), the per-webhook event databases (`events-{uuid}.db`), +and any archive databases written by `database` targets +(`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with `-{period}` +before `.db` for a target that rotates). Mount this as a persistent volume to preserve data across container restarts. -**The container sets its data directory's owner and mode itself -before the app starts**, so a host directory can be mounted as it is, -whoever owns it. The image's `ENTRYPOINT`, -`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if -it is missing, gives the directory and anything in it that belongs to -another user to `webhooker`, sets the directory to `0750`, and only -then runs the app as `webhooker`. Started with `--user`, it changes -nothing and runs the app as that user. +**The container sets its data directory's owner and mode itself before the app +starts**, so a host directory can be mounted as it is, whoever owns it. The +image's `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates +`DATA_DIR` if it is missing, gives the directory and anything in it that belongs +to another user to `webhooker`, sets the directory to `0750`, and only then runs +the app as `webhooker`. Started with `--user`, it changes nothing and runs the +app as that user. -**The file modes are not yours to set, and do not depend on the -directory.** `webhooker.db` holds target configuration in plaintext — -bearer tokens, API keys, Slack webhook URLs — along with the session -encryption key, so webhooker creates every SQLite file it owns `0600`: -each database and both of its `-wal` and `-shm` sidecars, across all -three tiers. Files an earlier build left `0644` are tightened when -they are opened. The directory's `0750` is defence in depth — it stops -other local users listing the directory and learning your webhook -UUIDs from the `events-{uuid}.db` filenames — not the barrier -protecting the credentials. +**The file modes are not yours to set, and do not depend on the directory.** +`webhooker.db` holds target configuration in plaintext — bearer tokens, API +keys, Slack webhook URLs — along with the session encryption key, so webhooker +creates every SQLite file it owns `0600`: each database and both of its `-wal` +and `-shm` sidecars, across all three tiers. Any of them found at another mode +is set to `0600` when the database is opened. The directory's `0750` is defence +in depth — it stops other local users listing the directory and learning your +webhook UUIDs from the `events-{uuid}.db` filenames — not the barrier protecting +the credentials. ### Running under upaas -[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this -repository's `Dockerfile` and runs it. The app needs: +[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's +`Dockerfile` and runs it. The app needs: -- **Network and port:** add no port mapping in upaas. upaas publishes - every mapped port on all interfaces of the host - ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)), - which would put the plain-HTTP admin UI and receiver there. Instead, - set the app's Docker Network in upaas to your reverse proxy's Docker - network; the proxy then reaches the app at `upaas-` followed by the - app name, port `8080`. Leave `PORT` unset: the image's health check - probes `8080`. +- **Network and port:** add no port mapping in upaas. upaas publishes every + mapped port on all interfaces of the host + ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)), which would + put the plain-HTTP admin UI and receiver there. Instead, set the app's Docker + Network in upaas to your reverse proxy's Docker network; the proxy then + reaches the app at `upaas-` followed by the app name, port `8080`. Leave + `PORT` unset: the image's health check probes `8080`. - **Volume:** one host directory mounted at `/var/lib/webhooker`. - **Environment variables:** - `WEBHOOKER_ENVIRONMENT=prod` - - `TRUSTED_PROXIES`: your reverse proxy's address on that Docker - network. The `remoteIP` field of the `http request` log line for a - request that came through the proxy shows it; the health check's - own lines show `::1`. See [Trusted proxies](#trusted-proxies). - - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets - `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to - `/var/lib/webhooker`. + - `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to your + reverse proxy's address alone if that address is outside those ranges, or + if any client can reach webhooker, or the proxy, from an RFC 1918 source + address (directly, or through anything that can rewrite source addresses, + such as NAT or a published container port). The `remoteIP` field of the + `http request` log line for a request that came through the proxy shows + that address; the health check's own lines show `::1`. See + [Trusted proxies](#trusted-proxies). + - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` + to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. - Everything else is optional; see [Configuration](#configuration). -- **Health check:** the image's own, which requests - `/.well-known/healthcheck`. upaas reads the container's health 60 - seconds after a deploy and marks the deploy failed unless it is - `healthy`. -- **First run:** the first start prints the `admin` password once, in - the banner described under [The admin account](#the-admin-account), - to the container's log. upaas names the container `upaas-` followed - by the app name, so for an app named `webhooker`: +- **Health check:** the image's own, which requests `/.well-known/healthcheck`. + upaas reads the container's health 60 seconds after a deploy and marks the + deploy failed unless it is `healthy`. +- **First run:** the first start prints the `admin` password once, in the banner + described under [The admin account](#the-admin-account), to the container's + log. upaas names the container `upaas-` followed by the app name, so for an + app named `webhooker`: ```bash docker logs upaas-webhooker ``` - If the password is lost, stop the container, set a new password with - the app's own image and volume, and start it again (see - [Recovering a lost admin password](#recovering-a-lost-admin-password)): + If the password is lost, stop the container, set a new password with the + app's own image and volume, and start it again (see + [Recovering a lost admin password](#recovering-a-lost-admin-password)): ```bash docker stop upaas-webhooker @@ -776,59 +757,56 @@ repository's `Dockerfile` and runs it. The app needs: ## Deployment behind a reverse proxy -webhooker terminates no TLS of its own. It serves plaintext HTTP and -expects a reverse proxy in front of it, which is the deployment it is -built for: the proxy holds the certificate, and webhooker binds -loopback where only the proxy can reach it. +webhooker terminates no TLS of its own. It serves plaintext HTTP and expects a +reverse proxy in front of it, which is the deployment it is built for: the proxy +holds the certificate, and webhooker binds loopback where only the proxy can +reach it. -Five things have to be right. Each one is silent when it is wrong — -the service comes up, serves pages, and is broken in a way nothing -reports. +Five things have to be right. Each one is silent when it is wrong — the service +comes up, serves pages, and is broken in a way nothing reports. -1. **Bind or firewall the app port.** The binary binds `127.0.0.1` by - default, so the cleartext listener is not published beside the - proxy. The image binds `0.0.0.0` inside its own network namespace - and relies on the publish address instead — - `-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy - and nothing else; widen it only with a firewall or a publish - address in front of it. A cleartext port answering the internet - serves the admin login form and the unauthenticated receiver with - no TLS at all, and the proxy in front of it changes nothing about - that. -2. **Make sure the environment is not `dev` (leave - `WEBHOOKER_ENVIRONMENT` unset or set it to `prod`), and make sure - the proxy sends `X-Forwarded-Proto`.** These are two requirements, - not one. The environment setting decides CORS and nothing else: - `dev` answers every origin with `Access-Control-Allow-Origin: *` - (without credentials), which a server-rendered production - deployment has no use for, and `prod` — the default — disables it. - Cookie `Secure` and the strict Origin/Referer mode are **not** tied - to it — they are decided per request from the transport, which - behind a proxy means the `X-Forwarded-Proto` header. The block below - sets it; without it every request is read as plaintext and cookies - ship without `Secure`. See [Configuration](#configuration). -3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate - limiter keys on the connecting peer, which behind a proxy is the - proxy on every request: all clients collapse into one global bucket - per limit and the receiver's per-IP limits become service-wide - ceilings. See [Trusted proxies](#trusted-proxies). List the proxy - and nothing else. -4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the - port. webhooker's Origin/Referer check compares against the host it - was given, so on any port other than 443 `$host` makes every form - POST — including login — fail with `403 origin invalid`, with - nothing in the error naming the cause. -5. **Keep the proxy's access log.** webhooker's own access log records - the peer address, which behind a proxy is always the proxy. The - proxy's log is the only record of which client sent what. nginx's - default `combined` format already logs `$remote_addr`; do not - replace it with one that drops the client address, and retain those - logs as long as you would want to answer a question about traffic. +1. **Bind or firewall the app port.** The binary binds `127.0.0.1` by default, + so the cleartext listener is not published beside the proxy. The image binds + `0.0.0.0` inside its own network namespace and relies on the publish address + instead — `-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy + and nothing else; widen it only with a firewall or a publish address in front + of it. A cleartext port answering the internet serves the admin sign-in form + and the unauthenticated receiver with no TLS at all, and the proxy in front + of it changes nothing about that. +2. **Make sure the environment is not `dev` (leave `WEBHOOKER_ENVIRONMENT` unset + or set it to `prod`), and make sure the proxy sends `X-Forwarded-Proto`.** + These are two requirements, not one. The environment setting decides CORS and + nothing else: `dev` answers every origin with + `Access-Control-Allow-Origin: *` (without credentials), which a + server-rendered production deployment has no use for, and `prod` — the + default — disables it. Cookie `Secure` and the strict Origin/Referer mode are + **not** tied to it — they are decided per request from the transport, which + behind a proxy means the `X-Forwarded-Proto` header. The block below sets it; + without it every request is read as plaintext and cookies ship without + `Secure`. See [Configuration](#configuration). +3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a proxy it + does not cover, every rate limiter keys on the proxy, so all clients share + one bucket per limit. Unset, the list is the RFC 1918 ranges, which do not + cover a proxy that reaches the binary itself over loopback (the binary bound + to `127.0.0.1`). With the image, the address to check is the `remoteIP` field + of the `http request` log line for a request that came through the proxy. If + any client can reach webhooker, or the proxy, from an RFC 1918 source + address, set the list to the proxy's address alone. See + [Trusted proxies](#trusted-proxies). +4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the port. + webhooker's Origin/Referer check compares against the host it was given, so + on any port other than 443 `$host` makes every form POST — including login — + fail with `403 origin invalid`, with nothing in the error naming the cause. +5. **Keep the proxy's access log.** webhooker's own access log names the client + in its `clientIP` field only while `TRUSTED_PROXIES` covers the proxy; the + proxy's log names it regardless. nginx's default `combined` format already + logs `$remote_addr`; do not replace it with one that drops the client + address, and retain those logs as long as you would want to answer a question + about traffic. ### nginx -Complete server block. Replace the `server_name` and the two -certificate paths. +Complete server block. Replace the `server_name` and the two certificate paths. ```nginx server { @@ -849,9 +827,8 @@ server { # webhooker's message. client_max_body_size 1m; - # $remote_addr is the client. webhooker's own log records this - # proxy and nothing else, so this file is the only place the - # client's address is written down. + # $remote_addr is the client. webhooker's own log names it, as + # clientIP, only while TRUSTED_PROXIES covers this proxy. access_log /var/log/nginx/webhooker.access.log combined; location / { @@ -881,19 +858,17 @@ server { } ``` -`X-Forwarded-For` must be **appended** to, which -`$proxy_add_x_forwarded_for` does. webhooker reads no other forwarded -client header: `X-Real-IP` and `True-Client-IP` are ignored from every -peer, so setting them has no effect. See +`X-Forwarded-For` must be **appended** to, which `$proxy_add_x_forwarded_for` +does. webhooker reads no other forwarded client header: `X-Real-IP` and +`True-Client-IP` are ignored from every peer, so setting them has no effect. See [Trusted proxies](#trusted-proxies) for how the chain is walked. -`X-Forwarded-Proto: https` is what tells webhooker the request arrived -over TLS, which decides the `Secure` flag on both the session and CSRF -cookies and the strict Origin/Referer mode. Without it, requests are -treated as plaintext and the cookies ship without `Secure`. Unlike -`X-Forwarded-For`, this header is read from any peer and is not gated -by `TRUSTED_PROXIES`, so the proxy must overwrite whatever a client -sent — `$scheme` above does. +`X-Forwarded-Proto: https` is what tells webhooker the request arrived over TLS, +which decides the `Secure` flag on both the session and CSRF cookies and the +strict Origin/Referer mode. Without it, requests are treated as plaintext and +the cookies ship without `Secure`. Unlike `X-Forwarded-For`, this header is read +from any peer and is not gated by `TRUSTED_PROXIES`, so the proxy must overwrite +whatever a client sent — `$scheme` above does. With that block, webhooker's environment is: @@ -902,83 +877,119 @@ BIND_ADDRESS=127.0.0.1 # the default; stated here to be explicit TRUSTED_PROXIES=127.0.0.1 ``` -If nginx runs on another host, `BIND_ADDRESS` becomes the address it -connects to, `TRUSTED_PROXIES` becomes nginx's address, and the port -must be firewalled to that address — the traffic between them is -cleartext. +If nginx runs on another host, `BIND_ADDRESS` becomes the address it connects +to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled +to that address — the traffic between them is cleartext. -### HSTS is always sent, and is not configurable +### HSTS -Every response carries -`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. -Two years, every subdomain, and a `preload` token. There is no setting -that changes or suppresses it. +Every response carries a `Strict-Transport-Security` header unless +`HSTS_MAX_AGE` is `0`. With the default `HSTS_MAX_AGE` of `87600h` (ten years) +it is: -This is worth knowing before the first request reaches a browser: a -client that sees it once will refuse plaintext HTTP to that hostname — -and to every subdomain of it — for two years, whatever else is served -there. Terminate TLS on a hostname you are prepared to keep on HTTPS. +``` +Strict-Transport-Security: max-age=315360000; includeSubDomains; preload +``` -## Backup, Restore, and Upgrades +`HSTS_MAX_AGE` sets `max-age`, in seconds. `includeSubDomains` and `preload` are +always part of the header. A browser that receives the header over HTTPS refuses +plaintext HTTP to that hostname until `max-age` runs out, whatever else is +served there, so terminate TLS on a hostname you are prepared to keep on HTTPS. +Browsers ignore the header when it arrives over plain HTTP. + +`includeSubDomains` extends this to every subdomain of the hostname webhooker is +served at. Served at `hooks.example.com`, it covers every name under +`hooks.example.com`; served at `example.com`, every name under `example.com`. + +`preload` invites browsers to put the domain on their built-in HSTS preload +lists. The token alone puts nothing there. A domain is listed after someone +submits it at hstspreload.org, which accepts only a whole registered domain such +as `example.com`, not `hooks.example.com`. A listed domain is HTTPS-only in +every browser that ships the list, even for users who have never visited it, and +removal takes months to reach those browsers, so treat a listing as permanent. + +Setting `HSTS_MAX_AGE=0` stops the header being sent, but a browser that already +received it over HTTPS does not forget it: it keeps refusing plaintext HTTP +until the `max-age` it saw runs out. Each header a browser receives over HTTPS +replaces the `max-age` it stored, so to end the policy sooner, set a short +`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it +up, or have a reverse proxy send `max-age=0`. + +## Backup and Restore ### What to back up -All persistent state lives in `DATA_DIR` (`/var/lib/webhooker` by -default). Back up that directory in full — the state is spread across -several files whose names depend on your data, so copying the directory -is both the simplest and the only complete rule: +All persistent state lives in `DATA_DIR` (`/var/lib/webhooker` by default). Back +up that directory in full — the state is spread across several files whose names +depend on your data, so copying the directory is both the simplest and the only +complete rule: -- `webhooker.db` — one per install. Settings (including the session - encryption key), users, API keys, webhooks, entrypoints, targets. -- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries, - delivery results. -- `archive-{webhook_uuid}.db` — **one per webhook that has a `database` - target**. Archived events. Keyed on the webhook UUID, not the target - UUID: a webhook with several `database` targets still has exactly one - archive file. +- `webhooker.db` — one per install. Settings (including the session encryption + key), users, API keys, webhooks, entrypoints, targets. +- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries, delivery + results. +- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per `database` + target**, or one per month, day or hour for a target that rotates, with + `-{period}` before `.db`. Archived events. The two names are made safe for a + file name, and the files are renamed when the webhook or the target is (see + [Database Architecture](#database-architecture)). -`{webhook_uuid}` is the webhook's UUID primary key in its canonical +`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their canonical 36-character hyphenated form, so a real filename looks like `events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the -always-empty [single-instance lock](#single-instance-lock); it holds no -state and is not part of the backup set — a copied one is stale and -blocks nothing. +always-empty [single-instance lock](#single-instance-lock); it holds no state +and is not part of the backup set — a copied one is stale and blocks nothing. -**`-wal` and `-shm` sidecars.** Every database runs in WAL journal mode, -so while the service is running each `{name}.db` has a `{name}.db-wal` -and a `{name}.db-shm` beside it. **`-wal` is part of the database, not a -scratch file**: it holds committed transactions that are not yet in the -`.db`, so a copy of the `.db` without its `-wal` is missing data and may -have no readable schema at all. `-shm` is regenerable, but there is no -reason to separate the two — copy the directory and you have them. +**`-wal` and `-shm` sidecars.** Every database runs in WAL journal mode, so +while the service is running each `{name}.db` has a `{name}.db-wal` and a +`{name}.db-shm` beside it. **`-wal` is part of the database, not a scratch +file**: it holds committed transactions that are not yet in the `.db`, so a copy +of the `.db` without its `-wal` is missing data and may have no readable schema +at all. `-shm` is regenerable, but there is no reason to separate the two — copy +the directory and you have them. -A clean shutdown closes every database, which checkpoints and removes -its sidecars; a killed or crashed instance leaves them, and they must be -carried with the `.db`. An archive the service has not opened since a -crash keeps that crash's sidecars, even across a later clean stop. +A clean shutdown closes every database, which checkpoints and removes its +sidecars; a killed or crashed instance leaves them, and they must be carried +with the `.db`. An archive the service has not opened since a crash keeps that +crash's sidecars, even across a later clean stop. -Configuration is **not** in `DATA_DIR` — it comes from the environment -and from a `.env` file read out of the process working directory. Back -that up with your deployment config, separately. +A missing sidecar is therefore normal, and SQLite makes new ones, so a `-wal` +lost from a copy cannot be reported: the transactions it held are simply gone. +SQLite reads a `-wal` up to its first damaged frame, as after a crash, and +rebuilds a damaged `-shm`. A sidecar with the wrong mode is set back to `0600` +when its database is opened. A directory in place of either is refused then, +with an error naming it: for `webhooker.db` the server and `webhooker resetpw` +stop, and an event or archive database fails as a damaged one does (see +[Database Architecture](#database-architecture)). + +**`DATA_DIR` must be on a local filesystem.** Under WAL, SQLite maps each +database's `-shm` file into memory, which network filesystems (NFS, SMB/CIFS) do +not support reliably, so they are not supported. webhooker does not check which +filesystem `DATA_DIR` is on. An error reading `disk I/O error (522)` +(`SQLITE_IOERR_SHORT_READ`) points at the storage under `DATA_DIR`, not at +locking; moving `DATA_DIR` to a plain local disk is the first thing to try. + +Configuration is **not** in `DATA_DIR` — it comes from the environment and from +a `.env` file read out of the process working directory. Back that up with your +deployment config, separately. ### A hot copy is not safe -Every database webhooker opens runs in WAL journal mode. The main and -event databases are also held open for the entire process lifetime — -`WebhookDBManager` caches event database handles and closes them only on -webhook deletion or shutdown — so "it looked idle" is not a guarantee -that nothing was mid-transaction. +Every database webhooker opens runs in WAL journal mode. The main and event +databases are also held open for the entire process lifetime — +`WebhookDBManager` caches event database handles and closes them only on webhook +deletion or shutdown — so "it looked idle" is not a guarantee that nothing was +mid-transaction. -That means `cp`, `rsync`, `tar` or a filesystem snapshot taken against a -running instance can capture a database and its `-wal` at two different -instants and yield a file that is corrupt or missing state. Copying a -`.db` on its own is worse and fails loudly: recently written pages, -including the schema itself on a young database, live in the `-wal`, so -the copy reads back as an empty or table-less database. Use one of the -two procedures below instead. +That means `cp`, `rsync`, `tar` or a filesystem snapshot taken against a running +instance can capture a database and its `-wal` at two different instants and +yield a file that is corrupt or missing state. Copying a `.db` on its own is +worse and fails loudly: recently written pages, including the schema itself on a +young database, live in the `-wal`, so the copy reads back as an empty or +table-less database. Use one of the two procedures below instead. -**Stop, copy, start.** The simplest, needs no extra tooling, and the -only one that gives a single point in time across every file: +**Stop, copy, start.** The simplest, needs no extra tooling, and the only one +that gives a single point in time across every file: ```bash docker stop webhooker @@ -994,349 +1005,356 @@ for db in /path/to/data/*.db; do done ``` -`.backup` reads through the WAL and writes a single consistent file with -no sidecars of its own, so the destination is complete as it stands. -Two caveats. First, the runtime image is `alpine:3.21` with only -`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in -it, so run this on the host against the volume path, or from a -throwaway container that mounts the volume. Second, each file is -captured at its own instant, so a webhook created or an event delivered -between two files being copied lands in one and not the other. If you -need the whole set coherent as of a single moment, stop the service. +`.backup` reads through the WAL and writes a single consistent file with no +sidecars of its own, so the destination is complete as it stands. Two caveats. +First, the runtime image is `alpine:3.21` with only `ca-certificates` and +`su-exec` added — the `sqlite3` CLI is **not** in it, so run this on the host +against the volume path, or from a throwaway container that mounts the volume. +Second, each file is captured at its own instant, so a webhook created or an +event delivered between two files being copied lands in one and not the other. +If you need the whole set coherent as of a single moment, stop the service. -Note that `sqlite3 .dump` is **not** one of these procedures: it is -an export, it holds a read transaction open for as long as it runs, and -it pins the WAL against checkpointing for that whole time. It is safe to -run — it does not block ingestion — but back up with `.backup` or a -stopped copy. +Note that `sqlite3 .dump` is **not** one of these procedures: it is an +export, it holds a read transaction open for as long as it runs, and it pins the +WAL against checkpointing for that whole time. It is safe to run — it does not +block ingestion — but back up with `.backup` or a stopped copy. -Archive databases are the one exception the service is built for: the -archive writer closes and reopens its handle around writes (debounced -to at most one reopen per second), so an operator can move -`archive-{uuid}.db` away for offline retention while the service runs, -and it is recreated on the next write. See -[Database Architecture](#database-architecture). That is a -move-the-file-away workflow, not a substitute for the backup procedures -above. +Archive databases are the one exception the service is built for: the archive +writer closes and reopens its handle around writes (debounced to at most one +reopen per second), so an operator can move an `archive-….db` away for offline +retention while the service runs, and it is recreated on the next write. The +webhook page names each `database` target's archive file. See +[Database Architecture](#database-architecture). That is a move-the-file-away +workflow, not a substitute for the backup procedures above. -**Move the sidecars with it.** Under WAL that workflow is no longer a -single file, and the common case is the dangerous one. The reopen -happens on the *next* write after the debounce window elapses, so after -the last write of a burst nothing checkpoints: measured, 20 s after ten -events the `archive-….db` was 4096 bytes — a header, no table — with -all ten rows sitting in a 189 KB `-wal`. Copying the `.db` alone at that -moment yields a file that opens with `no such table: archived_events`. -The file becomes self-contained again when the handle closes, which -happens on the next write past the debounce window, when the connection -pool retires the idle connection (about a minute after the last write), -or at the idle archive sweep — measured, the same file was a complete -20 KB `.db` with no sidecars about a minute after its last write. A -clean stop closes it too. So either move `archive-{uuid}.db` together -with any `-wal`/`-shm` beside it, or wait until there are none. +**Move the sidecars with it.** Under WAL that workflow is no longer a single +file, and the common case is the dangerous one. The reopen happens on the _next_ +write after the debounce window elapses, so after the last write of a burst +nothing checkpoints: measured, 20 s after ten events the `archive-….db` was 4096 +bytes — a header, no table — with all ten rows sitting in a 189 KB `-wal`. +Copying the `.db` alone at that moment yields a file that opens with +`no such table: archived_events`. The file becomes self-contained again when the +handle closes, which happens on the next write past the debounce window, when +the connection pool retires the idle connection (about a minute after the last +write), or at the idle archive sweep — measured, the same file was a complete 20 +KB `.db` with no sidecars about a minute after its last write. A clean stop +closes it too. So either move the `archive-….db` together with any `-wal`/`-shm` +beside it, or wait until there are none. ### Restore 1. Stop the service. -2. Restore the **whole set together**: `webhooker.db` *and* every - `events-*.db` *and* every `archive-*.db`. A partial restore fails - quietly rather than loudly. Every database is opened `mode=rwc`, so a - missing `events-{uuid}.db` is **created empty** on first access - instead of erroring — the webhook comes back with its configuration - intact and its entire event history silently gone. Event databases - restored without `webhooker.db` are simply orphaned; nothing +2. Restore the **whole set together**: `webhooker.db` _and_ every `events-*.db` + _and_ every `archive-*.db`. A restore that leaves out `webhooker.db` or an + `events-*.db` is reported, not refused; one that leaves out an `archive-*.db` + or a `-wal` (step 3) is not reported at all. Every database is opened + `mode=rwc`, so a missing `events-{uuid}.db` is **created empty**: the webhook + comes back with its configuration intact and its entire event history gone. + The first start after the restore logs `created a new, empty database` at + `WARN` for each such file, with its path, as it does for a missing + `webhooker.db`. A missing `archive-*.db` is recreated at its target's next + delivery without a warning, since moving one away is a supported workflow. + Event databases restored without `webhooker.db` are simply orphaned; nothing references their UUIDs. -3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup. - They are part of the database, and dropping a `-wal` silently - discards every transaction it still holds. An `.backup` set will not - contain any: it writes a single consolidated file per database. A - stop-and-copy set normally has none, because a clean stop closes - every database and checkpoints its sidecars away; the exception is an - archive not opened since a crash. A copy salvaged from a crashed - instance has them for everything, and needs all of them. +3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup. They are + part of the database, and dropping a `-wal` silently discards every + transaction it still holds. An `.backup` set will not contain any: it writes + a single consolidated file per database. A stop-and-copy set normally has + none, because a clean stop closes every database and checkpoints its sidecars + away; the exception is an archive not opened since a crash. A copy salvaged + from a crashed instance has them for everything, and needs all of them. -4. Start the service. The container gives the directory and the - restored files to the `webhooker` user before the app starts, - whoever restored them (see - [Running with Docker](#running-with-docker)). `AutoMigrate` runs - against each restored database as it is opened. - -### Upgrades - -**Back up before every upgrade.** Every start runs GORM `AutoMigrate` -unconditionally against whatever files it finds: - -- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`, - `Entrypoint`, `Target` -- each event database when it is lazily opened — `Event`, `Delivery`, - `DeliveryResult` -- each archive database on every open and reopen - -There is no schema version table, no migration ledger, and no down -migrations. Nothing in the files records which version wrote them, and -no code path undoes a migration. - -Upgrade procedure: - -1. Stop the service. -2. Back up `DATA_DIR` using one of the procedures above. -3. Pull the new image and start it. -4. Confirm `database migrations completed` in the logs before putting - traffic back on it. -5. Confirm the new build is the one running: - `curl -s http://host:8080/.well-known/healthcheck` reports the - version it was stamped with (see [Version stamping](#version-stamping)). - -**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions -always bound every interface. **Container deployments are unaffected** -— the image ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or -Compose service that worked before still works with nothing changed. - -A **bare binary** is the case that changes: the listener now binds -`127.0.0.1` unless `BIND_ADDRESS` says otherwise, so a deployment that -relied on reaching it from another host becomes unreachable until it -sets the address the proxy connects to. Check the `bindAddress` field -of the startup log to see what a running process bound. See -[Bind address](#bind-address). - -**Downgrade is unsupported.** Once a newer binary has migrated the files -there is no way to move them back. `AutoMigrate` is additive — it adds -tables, columns and indexes and never drops or rewrites them — so an -older binary will generally open migrated files and appear to work while -writing against a schema it does not know about. The failure mode is -silent divergence, not a startup error. The only supported way back to -an older version is restoring the pre-upgrade backup, which discards -everything received since that backup was taken. +4. Start the service. The container gives the directory and the restored files + to the `webhooker` user before the app starts, whoever restored them (see + [Running with Docker](#running-with-docker)). ### Version stamping -The binary reports its version at `/.well-known/healthcheck` (the -`version` field), in the UI footer, and in the startup log line -(`msg=starting`, `version=...`). It is also the Sentry release name, -as `webhooker-{version}`. The value is stamped in at build time by the -linker; it is not read from a file at runtime, so it identifies the -build itself. +The binary reports its version at `/.well-known/healthcheck` (the `version` +field), in the UI footer, and in the startup log line (`msg=starting`, +`version=...`). It is also the Sentry release name, as `webhooker-{version}`. +`curl -s http://host:8080/.well-known/healthcheck` shows which build is running. +The value is stamped in at build time by the linker; it is not read from a file +at runtime, so it identifies the build itself. -`script/version` produces the value and both build paths use it: +`script/version` produces the value for `make build`, and the image build runs +`make build` too; `script/docker` and `script/cibuild` run the same +`git describe --tags --always --dirty` on the host. All of them report: -| Build | What it reports | -| --- | --- | -| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` | -| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA | -| No tag reachable | the short SHA, e.g. `1a2b3c4` | -| Uncommitted changes | the above with a `-dirty` suffix | -| No git metadata | `unknown` | +| Build | What it reports | +| ----------------------- | --------------------------------------------------- | +| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` | +| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA | +| No tag reachable | the short SHA, e.g. `1a2b3c4` | +| Uncommitted changes | the above with a `-dirty` suffix | +| No git metadata | `unknown` | -`unknown` is what a source tarball or a `docker build .` with no -`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so -the build context carries no git metadata and the image cannot derive -the version itself: `script/docker` (and so `make docker`) resolves it -on the host and passes it in as the `VERSION` build arg. A build that -reports `unknown` is a build nobody told what it was; it is not a -failure, but it cannot be traced back to a commit. +The image derives it the same way, from the `.git` that the build context +carries, so any `docker build .` of a clone, with no build arguments, stamps the +commit it was built from; a shallow clone of one branch has no tags and stamps +the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any +tracked file, which git in the build would see as deleted, marking the version +`-dirty`. It does leave out every git `config` (`**/.git/config`, +`**/.git/modules/**/config`), which can hold a remote URL carrying a credential +and which `git describe` does not need, from a directory context. A context sent +as a tar is not filtered by `.dockerignore`, so it carries `.git/config` unless +its sender leaves it out; for upaas, that is +https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout +whoever owns its files, since a context sent as a tar archive keeps the sender's +owners and git otherwise refuses a checkout owned by another user. A `VERSION` +build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so +`make docker`) and `script/cibuild` pass the one they resolve on the host, or +`unknown` where git gives none. The image build fails if its context carries +`.git` and the version still comes out empty, `dev` or `unknown`, which means +git is missing from the build or could not read the checkout. + +`unknown` is what a source tarball, or a `docker build` with no `.git` in its +context and no `VERSION` build arg, reports. A build that reports `unknown` is a +build nobody told what it was; it is not a failure, but it cannot be traced back +to a commit. `make version` prints what the current checkout would stamp, and `make build VERSION=v1.2.3` overrides it. An empty override — from -`make build VERSION=` or from `--build-arg VERSION=` — means unset -rather than `""`, and resolves the way an absent one does. +`make build VERSION=` or from `--build-arg VERSION=` — means unset rather than +`""`, and resolves the way an absent one does. -Nothing that varies between two builds of the same commit is stamped — -no timestamp, no hostname, no builder identity — so two builds of one -commit still produce a byte-identical binary. +Nothing that varies between two builds of the same commit is stamped — no +timestamp, no hostname, no builder identity — so two builds of one commit still +produce a byte-identical binary. `make build` passes `-trimpath`, so the +directory it builds in is not recorded either, and `-s -w`, which leave out the +symbol table and debug information. ### Backups contain secrets -Treat a backup with the same care as the credentials inside it. Encrypt -backups at rest and restrict who can read them. +Treat a backup with the same care as the credentials inside it. Encrypt backups +at rest and restrict who can read them. -- `events-{uuid}.db` and `archive-{uuid}.db` hold the **full payload - body and headers** of every event as received, including whatever the - sending service put in them — tokens, signatures, personal data. -- Event databases written before - [issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed - **also contain target credentials**: a GORM association upsert on the - delivery and retry write path copied `targets` rows, `config` - included, into the per-webhook database. For a Slack target the - `webhookUrl` *is* the bearer credential, and an `http` target's URL - can embed userinfo. This version never writes those rows; the first - time it opens such a file it deletes them and vacuums the file, which - removes the credential bytes rather than only unlinking the rows. - Deleting alone would not: the bytes stay readable in the file's free - pages until it is rewritten. The sweep is recorded in the file's - `user_version` only once the vacuum returns, so a sweep that fails or - is interrupted fails the open and is retried on the next one, and a - file this version has opened without error holds no leaked rows and - no recoverable bytes from them. On upgrade this rewrites each - existing `events-{uuid}.db` once, on its first open. Two cases still - hand over live delivery destinations: a backup taken from an older - build, and a backup of a file this version has not yet opened - successfully. Copies already made stay affected — the sweep only - rewrites the file it opens, and freed blocks may persist in - filesystem snapshots and on the underlying storage. Rotate any target - credential that was in a backup you cannot account for. +- `events-{uuid}.db` and `archive-….db` hold the **full payload body and + headers** of every event as received, including whatever the sending service + put in them — tokens, signatures, personal data. - `webhooker.db` stores target config **unencrypted**, tracked at - [issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to - the session encryption key and the Argon2id password hashes. + [issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to the + session encryption key and the Argon2id password hashes. ## The entrypoint URL is the authentication secret -**The entrypoint UUID is the credential, and it is the only one.** -webhooker mints a version 4 UUID per entrypoint and serves it at -`/webhook/{uuid}`. Possession of that URL is the authentication: -anyone who holds it can submit events to the entrypoint, and the -receiver verifies nothing else about the sender. +**The entrypoint UUID is the credential, and it is the only one.** webhooker +mints a version 4 UUID per entrypoint and serves it at `/h/{uuid}`. Possession +of that URL is the authentication: anyone who holds it can submit events to the +entrypoint, and the receiver verifies nothing else about the sender. -There is no shared secret, no HMAC signature, no bearer token and no -second factor on the receiver, and none will be added. This was -considered and rejected; the implementation that existed was removed -in [PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing +There is no shared secret, no HMAC signature, no bearer token and no second +factor on the receiver, and none will be added. This was considered and +rejected; the implementation that existed was removed in +[PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing [issue #67](https://git.eeqj.de/sneak/webhooker/issues/67) and -[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A -proposal to reintroduce any of them — including as "defence in depth" -alongside the UUID — is answered by this section. Inbound signature -headers a sender sends anyway (`X-Hub-Signature` and its -per-provider equivalents) are stored and forwarded as ordinary -headers; nothing checks them. +[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A proposal to +reintroduce any of them — including as "defence in depth" alongside the UUID — +is answered by this section. Inbound signature headers a sender sends anyway +(`X-Hub-Signature` and its per-provider equivalents) are stored and forwarded as +ordinary headers; nothing checks them. What that means for an operator: -- **The URL is a capability, so treat it as a secret.** Keep it out of - logs, ticket bodies, chat messages and screenshots. Anyone who reads - it anywhere can post events as that sender. -- **Rotating means minting a new entrypoint, not changing a key.** - There is no way to rotate the UUID in place. To retire one, delete - the entrypoint (or deactivate it, which answers `410`) and create a - new one, then point the sender at the new URL. +- **The URL is a capability, so treat it as a secret.** Keep it out of logs, + ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can + post events as that sender. +- **Rotating means minting a new entrypoint, not changing a key.** There is no + way to rotate the UUID in place. To retire one, delete the entrypoint (or + deactivate it, which answers `410`) and create a new one, then point the + sender at the new URL. - **A sender that cannot be given a secret URL is a constraint on that - integration, not a reason to change this.** If a service only - supports signed payloads to a well-known URL, raise it as its own - problem — pick a different integration path, or accept that it - cannot be used. It is not grounds to reintroduce shared secrets. + integration, not a reason to change this.** If a service only supports signed + payloads to a well-known URL, raise it as its own problem — pick a different + integration path, or accept that it cannot be used. It is not grounds to + reintroduce shared secrets. ## Entrypoints This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow. Ten of the Makefile's seventeen targets are thin -shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and -`version` are inline commands with no script behind them, though -`build` and `version` both take their value from `script/version`. +development workflow. Thirteen of the Makefile's nineteen targets are thin shims +that call them; `build`, `run`, `dev`, `deps`, `clean` and `version` are inline +commands with no script behind them, though `build`, `run` and `dev` first run +`script/assets`, and `build` and `version` both take their value from +`script/version`. -`make check` needs the third-party browser assets in `static/`, which -are not committed, so run `make bootstrap` (or just `make assets`) once -after cloning. Without them the tests fail with a message naming that -remedy. `make check` does not fetch them itself because it must not -change any files in the repo. +`make build` and `make dev` each run `script/assets` first, which writes the +uncommitted `static/js/alpine.min.js` (see +[Third-party browser assets](#third-party-browser-assets)), so they work on a +fresh clone without a separate step. The Docker stages that compile the code run +it themselves. + +Every `docker build` in `script/` passes `--no-cache`: a check served from the +build cache is a check that did not run. We provide: - `script/bootstrap` — install all dependencies (idempotent) -- `script/setup` — make a fresh clone ready for development - (bootstrap, then install-precommit) +- `script/setup` — make a fresh clone ready for development (bootstrap, then + install-precommit) - `script/projectname` — output the project name ("webhooker") -- `script/fetch-assets` — download the third-party browser assets into - `static/`, verifying each against its pinned sha256 -- `script/test` — run the test suite -- `script/lint` — run golangci-lint in Docker (see Linting below) -- `script/fmt` — format all code (writes) +- `script/assets` — extract Alpine.js from its tarball in `3p/` (see + [Third-party browser assets](#third-party-browser-assets)) +- `script/test` — run the test suite: builds the Dockerfile's `test` phase, + tagged `webhooker-test` +- `script/test-browser` — run the browser test in Docker (see + [Third-party browser assets](#third-party-browser-assets)) +- `script/lint` — run the `gofmt` check, golangci-lint and ESLint: builds the + Dockerfile's `lint` phase, tagged `webhooker-lint` (see Linting below) +- `script/fmt` — format the Go code and, in Docker, the Markdown (writes) - `script/fmt-check` — check formatting (read-only) -- `script/check` — run test, lint, and fmt-check +- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see + [Stylesheet](#stylesheet)) +- `script/css-check` — fail if `static/css/tailwind.css` differs from what + `script/css` would generate (read-only) +- `script/check` — run test, lint, fmt-check, and css-check - `script/version` — output the version to stamp into the binary (see [Version stamping](#version-stamping)) -- `script/docker` — build the Docker image tagged via - `script/projectname`, passing `script/version`'s output in as the - `VERSION` build arg -- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - runs the checks, so a green build implies a green repo) -- `script/ci-mark-superseded` — CI helper: mark the commits whose run a - newer push cancelled (see [CI gate honesty](#ci-gate-honesty)) +- `script/docker` — build the Docker image tagged via `script/projectname`, + passing the version `git describe` gives on the host in as the `VERSION` build + arg +- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`, + then the same image build as `script/docker`, whose gate phases run again (see + [CI gate honesty](#ci-gate-honesty)) - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) -- `script/install-precommit` — install the git pre-commit hook that - runs `script/precommit` +- `script/install-precommit` — install the git pre-commit hook that runs + `script/precommit` ## Third-party browser assets -The web UI serves one third-party script, Alpine.js. It is **not** committed: -a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars -both committed build artifacts and unpinned external references. +The web UI serves one third-party script, Alpine.js, in its CSP build: the npm +package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which +the standard `alpinejs` build needs to run the expressions written in the +markup. The CSP build runs no expressions, so every Alpine directive in +`templates/` only names a property or method of a component registered in +`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never +`x-data="{ open: false }"` or `@click="open = !open"`. -Instead `script/fetch-assets` downloads it from a pinned URL, checks the -download against a hardcoded sha256, and installs it under `static/`. The -sha256 of every installed asset is recorded in `static/vendor.sha256`, and -`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary -against that manifest — so the pin is enforced on what actually ships, not -merely written down. Any mismatch fails the build. +A browser test in `internal/server` loads the webhook page, its edit pages and +the event log under the real policy and checks that: the add entrypoint form +stays hidden until Add is clicked; for every target type, the targets section's +Add shows only a choice of type with Next and Cancel, Next shows only that +type's fields (no url field for `database` or `log`), Cancel at either step +closes the form, and saving adds the target; a refused target comes back with +its form open, the values entered and the reason, and after Cancel the next Add +starts with an empty form and no reason; a refused save on the target edit page +and on the webhook edit page comes back with the reason and every value entered; +the Copy button beside an entrypoint URL reads "Copied" once clicked; an +entrypoint's Edit button shows its edit form in place of its description and +hides until the form closes, Cancel hides the form and drops what was typed, as +does leaving the page and going back to it, and Save changes the description; of +the recent events on the webhook page only the newest starts expanded, each +expands and collapses, and Open leads to the event's own page; of the events in +the event log only the newest starts expanded, and an event there expands and +collapses when its row's caret or its ID is clicked, and from the keyboard, but +not when its ID is selected with the mouse, and a delivery's attempts inside it +expand and collapse; and at phone width the menu button opens and closes the +mobile menu, and neither the webhook page nor the event log, with a delivery's +attempts open, scrolls sideways or cuts anything off at the page's or a card's +edge. It also fails if the browser reports a console warning or error, an +uncaught exception, or anything the policy refused. `make check` and the image +build lint it but do not run it, and `make test` leaves it out (its file is +built only with the `browser` build tag). Run it with `make test-browser` after +changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which +runs the test in a digest-pinned headless browser image, so the host needs no +browser. -`make bootstrap` runs the fetch for local development, and the Dockerfile -runs it in the build stage; `.gitignore` and `.dockerignore` keep the -artifact out of both the repo and the build context. +The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for +byte as the npm registry publishes it. It is a dependency, not this repo's build +output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not +apply. The directory is `3p/` rather than `vendor/` because Go treats a root +`vendor/` directory as its module vendor directory. -To move to a new version: update the version, URL, and tarball sha256 in -`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then -run `make assets && make check`. +`script/assets` (`make assets`) extracts the browser build, +`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where +`go:embed` picks it up. `make build` and `make dev` run it first, and so do the +Dockerfile's lint, test and build stages, so nothing downloads Alpine.js. The +extracted file is not committed, and `.dockerignore` keeps any host copy out of +the build context. `static/static.go` names every file it embeds, so a build +that skips the extraction, such as a bare `go build`, fails with an error naming +`js/alpine.min.js`. + +To move to a new version: download +`https://registry.npmjs.org/@alpinejs/csp/-/csp-.tgz`, check it against +the `dist.integrity` hash listed at +`https://registry.npmjs.org/@alpinejs/csp/`, replace the tarball in +`3p/` with it as `alpinejs-csp-.tgz`, update its file name in +`script/assets`, and run `make check` and `make test-browser`. + +## Stylesheet + +`static/css/tailwind.css` is generated by Tailwind and committed. To change the +styles, edit the templates, `static/js/app.js`, +`internal/handlers/recent_events.go` or `static/css/input.css`, run `make css`, +and commit the regenerated file with the change. Tailwind takes classes only +from the files that `input.css` names in its `@source` lines; a class written in +any other file is not generated until that file is named there too. `make check` +and the image build fail when the committed file differs from what `make css` +generates. `static/css/style.css` is hand-written and is not generated. + +`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256 +pinned in the Dockerfile's stylesheet stages; it is never installed on the host. +To move to a new version, change the version in both download URLs and both +sha256 sums, taken from the release's `sha256sums.txt`, then run `make css` and +commit the result. ## Rationale -Webhook integrations between services are inherently fragile. The -receiving service must be online when the webhook fires, most webhook -senders provide no built-in retry mechanism, and there is no standard -way to inspect what was sent, when it was sent, or whether delivery -succeeded. +Webhook integrations between services are inherently fragile. The receiving +service must be online when the webhook fires, most webhook senders provide no +built-in retry mechanism, and there is no standard way to inspect what was sent, +when it was sent, or whether delivery succeeded. webhooker solves this by acting as a durable intermediary: 1. **Reliable ingestion** — webhooker is always ready to accept incoming - webhooks. It stores every received event before attempting any - delivery, so nothing is lost if downstream targets are unavailable. + webhooks. It stores every received event before attempting any delivery, so + nothing is lost if downstream targets are unavailable. -2. **Guaranteed delivery** — Events are queued for delivery to each - configured target. Failed deliveries are retried with configurable - backoff. Every delivery attempt is logged with status codes, response - bodies, and timing. +2. **Guaranteed delivery** — Events are queued for delivery to each configured + target. Failed deliveries are retried with configurable backoff. Every + delivery attempt is logged with status codes, response bodies, and timing. - **That guarantee is at-least-once, not exactly-once.** When a send - reaches its target but the write recording that outcome fails, the - delivery is deliberately left in a recoverable state rather than - marked done — losing a delivery is the worse failure — so the - pending sweep picks it up about fifteen minutes later, or the next - restart does, and the target receives a payload it already got. - webhooker adds no delivery identifier of its own to an outbound - request, so **make your receiver idempotent** against whatever the - payload itself carries. + **That guarantee is at-least-once, not exactly-once.** When a send reaches + its target but the write recording that outcome fails, the delivery is + deliberately left in a recoverable state rather than marked done — losing a + delivery is the worse failure — so the pending sweep picks it up about + fifteen minutes later, or the next restart does, and the target receives a + payload it already got. webhooker adds no delivery identifier of its own to + an outbound request, so **make your receiver idempotent** against whatever + the payload itself carries. -3. **Observability** — Full request/response logging for every webhook - received and every delivery attempted. Prometheus metrics expose - volume, latency, and error rates. The web UI provides real-time - visibility into event flow. +3. **Observability** — Full request/response logging for every webhook received + and every delivery attempted. Prometheus metrics expose volume, latency, and + error rates. The web UI provides real-time visibility into event flow. -4. **Fan-out** — A single incoming webhook can be delivered to multiple - targets simultaneously. This enables patterns like forwarding a - GitHub webhook to both a deployment service and a Slack channel. +4. **Fan-out** — A single incoming webhook can be delivered to multiple targets + simultaneously. This enables patterns like forwarding a GitHub webhook to + both a deployment service and a Slack channel. -5. **Replay and resubmit** — Every received event is stored in full, - and the event log offers two redelivery actions built on that. - **Replay** re-sends one finished delivery to its own target, for - recovering a delivery that failed. **Resubmit** re-injects the stored - event as a new undelivered event and fans it out to every currently - active target, for firing captured traffic at a backend under - development. Both are web UI actions; there is no API for either. +5. **Replay and resubmit** — Every received event is stored in full, and the + event log offers two redelivery actions built on that. **Replay** re-sends + one finished delivery to its own target, for recovering a delivery that + failed. **Resubmit** re-injects the stored event as a new undelivered event + and fans it out to every currently active target, for firing captured traffic + at a backend under development. Both are web UI actions; there is no API for + either. ### Use Cases -- **Store-and-forward** with configurable retries for unreliable - receivers -- **Observability** via Prometheus metrics on webhook frequency, payload - size, and delivery performance +- **Store-and-forward** with configurable retries for unreliable receivers +- **Observability** via Prometheus metrics on webhook frequency, payload size, + and delivery performance - **Debugging** and introspection of webhook payloads in the web UI - **Resubmit** of captured webhook events for application testing and development, and **replay** of a single failed delivery for recovery -- **Fan-out** delivery of a single webhook to multiple downstream - targets -- **High-availability ingestion** for delivery to less reliable backend - systems +- **Fan-out** delivery of a single webhook to multiple downstream targets +- **High-availability ingestion** for delivery to less reliable backend systems ## Design @@ -1346,29 +1364,29 @@ webhooker is structured as a standard Go HTTP server following the [sneak/prompts GO_HTTP_SERVER_CONVENTIONS](https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md). It uses: -- **[Uber fx](https://go.uber.org/fx)** for dependency injection and - lifecycle management +- **[Uber fx](https://go.uber.org/fx)** for dependency injection and lifecycle + management - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[GORM](https://gorm.io)** for database access with - **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as - the runtime SQLite driver. Note: `gorm.io/driver/sqlite` transitively - depends on `mattn/go-sqlite3`, which requires CGO at build time (see - [Docker](#docker) section) -- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured - logging with TTY detection (text for dev, JSON for prod) -- **[gorilla/sessions](https://github.com/gorilla/sessions)** for - encrypted cookie-based session management -- **[gorilla/csrf](https://github.com/gorilla/csrf)** for CSRF - protection (cookie-based double-submit tokens) -- **[go-chi/httprate](https://github.com/go-chi/httprate)** for - sliding-window rate limiting of the password-change and webhook - receiver endpoints. The bucket is per client IP only when - `TRUSTED_PROXIES` names the reverse proxy; unset, every client - behind that proxy shares one bucket per limit. The login endpoint - counts failed attempts itself instead, so that a correct password is - never throttled (see [Rate Limiting](#rate-limiting)) -- **[Prometheus](https://prometheus.io)** for metrics, served at - `/metrics` behind basic auth + **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime + SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on + `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker) + section) +- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with + TTY detection (text for dev, JSON for prod) +- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted + cookie-based session management +- **[gorilla/csrf](https://github.com/gorilla/csrf)** for CSRF protection + (cookie-based double-submit tokens) +- **[go-chi/httprate](https://github.com/go-chi/httprate)** for sliding-window + rate limiting of the password-change and webhook receiver endpoints. The + bucket is per client IP only when `TRUSTED_PROXIES` covers the reverse proxy + (by default it covers the RFC 1918 private ranges); otherwise every client + behind that proxy shares one bucket per limit. The login endpoint counts + failed attempts itself instead, so that a correct password is never throttled + (see [Rate Limiting](#rate-limiting)) +- **[Prometheus](https://prometheus.io)** for metrics, served at `/metrics` + behind basic auth - **[Sentry](https://sentry.io)** for optional error reporting ### Naming Conventions @@ -1376,17 +1394,17 @@ It uses: The codebase uses consistent naming throughout (rename completed in [issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)): -| Entity | Description | -| ---------------- | ----------- | -| **Webhook** | Top-level configuration entity grouping entrypoints and targets | -| **Entrypoint** | A receiver URL where external services POST events | -| **Target** | A delivery destination for events | +| Entity | Description | +| -------------- | --------------------------------------------------------------- | +| **Webhook** | Top-level configuration entity grouping entrypoints and targets | +| **Entrypoint** | A receiver URL where external services POST events | +| **Target** | A delivery destination for events | ### Data Model -webhooker's data model has nine entities organized into two tiers: the -**application tier** (user and webhook configuration) and the **event -tier** (event ingestion, delivery, and logging). +webhooker's data model has eleven entities organized into two tiers: the +**application tier** (user and webhook configuration) and the **event tier** +(event ingestion, delivery, and logging). ``` ┌─────────────────────────────────────────────────────────────┐ @@ -1413,19 +1431,29 @@ tier** (event ingestion, delivery, and logging). │ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ │ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │ │ └──────────┘ └──────────┘ └─────────────────┘ │ +│ │ +│ ┌──────────────┐ (one row: running counts of events) │ +│ │ EventTotals │ │ +│ └──────────────┘ │ +│ ┌──────────────┐ (one row per target: running counts │ +│ │ TargetTotals │ of its deliveries) │ +│ └──────────────┘ │ +│ ┌──────────────────┐ (one row per entrypoint: when the │ +│ │ EntrypointTotals │ last event arrived on its URL) │ +│ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` #### Setting -A key-value pair for application-level configuration that is -auto-managed rather than user-provided. Used to store the session -encryption key and any future auto-generated settings. +A key-value pair for application-level configuration that is auto-managed rather +than user-provided. Used to store the session encryption key and any future +auto-generated settings. -| Field | Type | Description | -| ------- | ------ | ----------- | +| Field | Type | Description | +| ------- | ------ | -------------------------- | | `key` | string | Primary key (setting name) | -| `value` | text | Setting value | +| `value` | text | Setting value | Currently stored settings: @@ -1436,467 +1464,677 @@ Currently stored settings: A registered user of the webhooker service. -| Field | Type | Description | -| ---------- | -------- | ----------- | -| `id` | UUID | Primary key | -| `username` | string | Unique login name | -| `password` | string | Argon2id hash (never exposed via API) | +| Field | Type | Description | +| ---------- | ------ | --------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie | +| `password` | string | Argon2id hash (never exposed via API) | **Relations:** Has many Webhooks. Has many APIKeys. -Passwords are hashed with Argon2id using secure defaults (64 MB memory, -1 iteration, 4 threads, 32-byte key, 16-byte salt). On first startup, -an `admin` user is created with a randomly generated 16-character -password printed once to stdout; `webhooker resetpw` sets it again if -it is lost (see [The admin account](#the-admin-account)). Every one of -those paths hashes through the same `internal/database` code, so the -parameters cannot drift between them. +Passwords are hashed with Argon2id using secure defaults (64 MB memory, 1 +iteration, 4 threads, 32-byte key, 16-byte salt). On first startup, an `admin` +user is created with a randomly generated 16-character password printed once to +stdout; `webhooker resetpw` sets it again if it is lost (see +[The admin account](#the-admin-account)). Every one of those paths hashes +through the same `internal/database` code, so the parameters cannot drift +between them. #### Webhook -The top-level configuration entity. A webhook groups together one or -more entrypoints (receiver URLs) and one or more targets (delivery -destinations) into a logical unit. A user creates a webhook to set up -event routing. +The top-level configuration entity. A webhook groups together one or more +entrypoints (receiver URLs) and one or more targets (delivery destinations) into +a logical unit. A user creates a webhook to set up event routing. -| Field | Type | Description | -| ---------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `name` | string | Human-readable name | -| `description` | string | Optional description | +The new webhook form can also give the webhook its first targets: an optional +HTTP target URL creates an `http` target named `HTTP`, and the archive checkbox +creates a `database` target named `Archive` whose `expiry` is the archive expiry +chosen beside it (never, 1h, 12h, 24h, 30d, 90d or 365d) and whose `rotation` is +the rotation chosen below that (none, monthly, daily or hourly). Both are +validated as on the add target form, and the webhook and its targets are created +together or not at all. + +| Field | Type | Description | +| ---------------- | ------- | ----------------------------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `name` | string | Human-readable name | +| `description` | string | Optional description | | `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) | **Relations:** Belongs to User. Has many Entrypoints. Has many Targets. -The `retention_days` field controls how long event data is kept in the -webhook's dedicated database before automatic cleanup. +The `retention_days` field controls how long event data is kept in the webhook's +dedicated database before automatic cleanup. -Setting `retention_days` to `0` means "retain events forever". Because -the column carries a default of 30, a literal zero cannot survive an -insert, so a zero is rewritten on save to a sentinel of `365 * 1000` -days (`database.RetentionForeverDays`). The retention reaper recognises -that sentinel and skips the webhook entirely, and the web UI displays -such a webhook's retention as "forever" rather than as a day count. +Setting `retention_days` to `0` means "retain events forever". Because the +column carries a default of 30, a literal zero cannot survive an insert, so a +zero is rewritten on save to a sentinel of `365 * 1000` days +(`database.RetentionForeverDays`). The retention reaper recognises that sentinel +and skips the webhook entirely, and the web UI displays such a webhook's +retention as "forever" rather than as a day count. -Submitted `retention_days` values therefore fall into three bands, not -two: +Submitted `retention_days` values therefore fall into three bands, not two: -- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 - years) is accepted as a finite retention. +- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is + accepted as a finite retention. - Above that ceiling but below the retain-forever sentinel of 365000 - (`database.RetentionForeverDays`) is rejected with a 400. This is the - band the cap exists for. -- `0`, and `365000` or above, are accepted and mean retain forever, - collapsing to the sentinel — `0` in `Webhook.BeforeSave`, the large - values in `parseRetentionDays`. The large values are not out of - range: the edit form pre-fills the sentinel for a retain-forever - webhook, so submitting that form back unchanged has to keep meaning - "forever". + (`database.RetentionForeverDays`) is rejected with a 400. This is the band the + cap exists for. +- `0`, and `365000` or above, are accepted and mean retain forever, collapsing + to the sentinel — `0` in `Webhook.BeforeSave`, the large values in + `parseRetentionDays`. The large values are not out of range: the edit form + pre-fills the sentinel for a retain-forever webhook, so submitting that form + back unchanged has to keep meaning "forever". -A negative value is in none of the three: `parseRetentionDays` rejects -it with a 400 before `BeforeSave` ever sees it. +A negative value is in none of the three: `parseRetentionDays` rejects it with a +400 before `BeforeSave` ever sees it. -The cap is not arbitrary: the reaper computes its cutoff as a -`time.Duration`, an int64 nanosecond count, and a longer period -overflows it. An overflowed cutoff lands in the future, where it -matches every row, so the sweep would delete every event the webhook -has instead of none. The reaper also clamps the value it is given, so a -row written by an older version cannot trigger that either. +The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, +an int64 nanosecond count, and a longer period overflows it. An overflowed +cutoff lands in the future, where it matches every row, so the sweep would +delete every event the webhook has instead of none. The reaper also clamps the +value it reads to the cap, so no stored value can trigger that either. #### Entrypoint -A receiver URL where external services POST webhook events. Each -entrypoint has a unique UUID-based path. -When an HTTP request arrives at an entrypoint's path, webhooker captures -the full request and creates an Event. +A receiver URL where external services POST webhook events. Each entrypoint has +a unique UUID-based path. When an HTTP request arrives at an entrypoint's path, +webhooker captures the full request and creates an Event. -| Field | Type | Description | -| -------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) | -| `description` | string | Optional description | -| `active` | boolean | Whether this entrypoint accepts events (default: true) | +| Field | Type | Description | +| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) | +| `description` | string | Optional description | +| `active` | boolean | Whether this entrypoint accepts events (default: true) | **Relations:** Belongs to Webhook. -A webhook can have multiple entrypoints. This allows separate URLs for -different event sources that all feed into the same processing pipeline -(e.g., one entrypoint for GitHub, another for Stripe, both routing to -the same targets). +A webhook can have multiple entrypoints. This allows separate URLs for different +event sources that all feed into the same processing pipeline (e.g., one +entrypoint for GitHub, another for Stripe, both routing to the same targets). + +The webhook page shows, for each entrypoint, when the last event arrived on its +URL, which retention leaves in place, or "never" if none ever has, and how many +events arrived on it within the webhook's retention period. A resubmitted event +did not arrive on the URL and counts in neither. #### Target -A delivery destination for events. Each target defines where and how -events should be forwarded. +A delivery destination for events. Each target defines where and how events +should be forwarded. -| Field | Type | Description | -| ---------------- | ---------- | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | -| `active` | boolean | Whether deliveries are enabled (default: true) | -| `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets | -| `max_queue_size` | integer | Stored and shown on the target's detail view, but not enforced anywhere yet: nothing in the delivery engine consults it. Queue depth is set by the two fixed 10,000-entry channels | +| Field | Type | Description | +| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `name` | string | Human-readable name | +| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | +| `active` | boolean | Whether deliveries are enabled (default: true) | +| `config` | JSON text | Type-specific configuration | +| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. **Target types:** - **`http`** — Forward the event as an HTTP POST to a configured URL. - `max_retries` is the total number of delivery attempts, not retries on - top of the first: when `max_retries` is 0 (the default), the target - operates in fire-and-forget mode, a single attempt with no retries and - no circuit breaker; a value of N makes up to N attempts in all, - retrying failed deliveries with exponential backoff and protecting them - with a per-target circuit breaker. -- **`slack`** — Post the event as a formatted message to a - Slack-compatible incoming webhook URL (`webhookUrl` in `config`). It - is built on the same HTTP core as `http` and honours `max_retries` - identically, circuit breaker included. See the Slack target section - under "Per-Webhook Event Databases" for the message format. -- **`database`** — Archive the full event as a row into a separate - per-webhook archive database (`archive-{webhookID}.db`) for long-term - retention, with an optional creation-validated expiry (default: keep - forever). No external delivery and no retries; an archive write - failure fails the delivery. See the database target section under - "Per-Webhook Event Databases" for the full semantics. -- **`log`** — Write the event to the application log (stdout). Useful - for debugging. + `max_retries` is the total number of delivery attempts, not retries on top of + the first: when `max_retries` is 0 (the default), the target operates in + fire-and-forget mode, a single attempt with no retries and no circuit breaker; + a value of N makes up to N attempts in all, retrying failed deliveries with + exponential backoff and protecting them with a per-target circuit breaker. +- **`slack`** — Post the event as a formatted message to a Slack-compatible + incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP + core as `http` and honours `max_retries` identically, circuit breaker + included. See the Slack target section under "Per-Webhook Event Databases" for + the message format. +- **`database`** — Archive the full event as a row into the target's own archive + database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for + long-term retention, with an optional creation-validated expiry (default: keep + forever) and rotation (default: none, one file). The new webhook form, the add + target form and the target edit form all offer the same expiries: never, 1h, + 12h, 24h, 30d, 90d or 365d, and the same rotations: none, monthly, daily or + hourly. The target list shows the expiry in plain units, such as "30 days", + and the rotation. No external delivery and no retries; an archive write + failure fails the delivery. See the database target section under "Per-Webhook + Event Databases" for the full semantics. The web UI calls this type an + archive: its badge, the add target form's type list and the target edit page + say so, and its settings are labelled Archive expiry and Archive rotation. +- **`log`** — Write the event to the application log (stdout). Useful for + debugging. -The `config` field stores type-specific configuration as JSON (e.g., -destination URL, custom headers, timeout settings). +The `config` field stores type-specific configuration as JSON (e.g., destination +URL, custom headers, timeout settings). **`http` target configuration:** -| Key | Type | Description | -| --------- | ------------- | ----------- | -| `url` | string | Destination the event is POSTed to | -| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| Key | Type | Description | +| -------------- | ------------- | ----------------------------------------------------------------------------------------- | +| `url` | string | Destination the event is POSTed to | +| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | +| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not | -`timeout` is capped at **300 seconds**, and the form rejects anything -above it rather than substituting the cap. A delivery attempt holds one -of the bounded pool's workers for its whole duration, so an unbounded -timeout would let a single unresponsive destination stall the queue. +`forwardQuery` is off by default, and the target URL is then sent exactly as +configured. On, each delivery appends the event's query string to the target +URL, joined with `&` when the URL already has a query string of its own; a +replayed delivery and a resubmitted event's deliveries do the same. Both target +forms offer it as "Pass the query string on to this target", and the target list +shows it when it is on. -`headers` rejects the names the delivery path or `net/http` writes -regardless of what is configured: `Host`, `Content-Length`, -`Transfer-Encoding`, `Connection`, `Trailer` and `User-Agent`. These are -refused at the form rather than accepted and ignored, because a stored -header that provably never reaches the wire tells the operator their -configuration took effect when it did not. `Content-Type` is _not_ -reserved: a configured one deliberately overrides the event's. +`timeout` is capped at **300 seconds**, and the form rejects anything above it +rather than substituting the cap. A delivery attempt holds one of the bounded +pool's workers for its whole duration, so an unbounded timeout would let a +single unresponsive destination stall the queue. -**Redirects.** A redirect from an `http` target's destination is -followed, up to ten hops, and the delivery's recorded status and body -come from the final hop. One rule governs every header the delivery -carries for someone else — the configured `headers` and the inbound -event headers forwarded from the sender alike: **a hop that leaves the -origin the target names carries none of them.** Leaving the origin -means a different host, a different port, or a step down from `https` -to `http`. Both classes routinely carry a secret — a configured -`X-Api-Key` or `PRIVATE-TOKEN`, an inbound `X-Hub-Signature` — and an -open redirect at the destination would otherwise hand it to a host the -operator never chose. `net/http` already does this for `Authorization` -and `Cookie`. The delivery path's own headers (`Content-Type`, -`User-Agent`) are not origin-scoped and always travel, so a body -preserved across a `307` is still typed. A `301`, `302` or `303` is a -different matter, and this is `net/http`'s behaviour rather than -webhooker's: the POST becomes a GET and the event body and its -`Content-Type` are dropped, so the destination the chain ends at -receives no event at all — and the delivery is still recorded -`Delivered` on that hop's `2xx`. Redirects within the target's own -origin keep everything, so a destination that redirects its own paths -is unaffected; the drop is per hop rather than permanent, so a chain -that returns to the configured origin carries the headers again, -exactly as `net/http` treats `Authorization`. Each hop is dialled -through the same SSRF guard as the first, so a redirect aimed at a +`headers` rejects the names the delivery path or `net/http` writes regardless of +what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, +`Trailer` and `User-Agent`. These are refused at the form rather than accepted +and ignored, because a stored header that provably never reaches the wire tells +the operator their configuration took effect when it did not. `Content-Type` is +_not_ reserved: a configured one deliberately overrides the event's. + +**Redirects.** A redirect from an `http` target's destination is followed, up to +ten hops, and the delivery's recorded status and body come from the final hop. +One rule governs every header the delivery carries for someone else — the +configured `headers` and the inbound event headers forwarded from the sender +alike: **a hop that leaves the origin the target names carries none of them.** +Leaving the origin means a different host, a different port, or a step down from +`https` to `http`. Both classes routinely carry a secret — a configured +`X-Api-Key` or `PRIVATE-TOKEN`, an inbound `X-Hub-Signature` — and an open +redirect at the destination would otherwise hand it to a host the operator never +chose. `net/http` already does this for `Authorization` and `Cookie`. The +delivery path's own headers (`Content-Type`, `User-Agent`) are not origin-scoped +and always travel, so a body preserved across a `307` is still typed. A `301`, +`302` or `303` is a different matter, and this is `net/http`'s behaviour rather +than webhooker's: the POST becomes a GET and the event body and its +`Content-Type` are dropped, so the destination the chain ends at receives no +event at all — and the delivery is still recorded `Delivered` on that hop's +`2xx`. Redirects within the target's own origin keep everything, so a +destination that redirects its own paths is unaffected; the drop is per hop +rather than permanent, so a chain that returns to the configured origin carries +the headers again, exactly as `net/http` treats `Authorization`. Each hop is +dialled through the same SSRF guard as the first, so a redirect aimed at a private or reserved address is refused at connect time. #### APIKey A programmatic access credential for API authentication. -| Field | Type | Description | -| -------------- | --------- | ----------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `key` | string | Unique API key value | -| `description` | string | Optional description | +| Field | Type | Description | +| -------------- | --------- | -------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `key` | string | Unique API key value | +| `description` | string | Optional description | | `last_used_at` | timestamp | Last time this key was used (nullable) | **Relations:** Belongs to User. #### Event -A captured incoming webhook request. Stores the complete HTTP request -data for auditing, for replay, and for resubmission. +A captured incoming webhook request. Stores the complete HTTP request data for +auditing, for replay, and for resubmission. -| Field | Type | Description | -| -------------- | ------ | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `entrypoint_id` | UUID | Foreign key → Entrypoint | -| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | -| `headers` | JSON | Complete request headers | -| `body` | text | Raw request body | -| `content_type` | string | Content-Type header value | -| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain | +| Field | Type | Description | +| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `entrypoint_id` | UUID | Foreign key → Entrypoint | +| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | +| `raw_query` | text | The query string of the captured request, as sent, without the leading `?`; empty when there was none. A resubmitted copy carries its original's | +| `headers` | JSON | Complete request headers | +| `body` | text | Raw request body | +| `content_type` | string | Content-Type header value | +| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit | +| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain | -**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many -Deliveries. +**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries. -When a request arrives at an entrypoint, the full request (method, -headers, body) is captured as an Event. The event is then queued for -delivery to every active target configured on the parent webhook. +When a request arrives at an entrypoint, the full request (method, query string, +headers, body) is captured as an Event. The event is then queued for delivery to +every active target configured on the parent webhook. + +The event log and the event's own page show the query string with the rest of +the request. The event log leaves out one larger than 32 KiB, as it does request +headers, and links to the event's page, which shows it whole. The `database` and +`log` targets carry it with the rest of the event. An `http` target receives it +only when its `forwardQuery` setting is on. #### Delivery -The pairing of an event with a target. Tracks the overall delivery -status across potentially multiple attempts. +The pairing of an event with a target. Tracks the overall delivery status across +potentially multiple attempts. -| Field | Type | Description | -| ---------- | -------------- | ----------- | -| `id` | UUID | Primary key | -| `event_id` | UUID | Foreign key → Event | -| `target_id`| UUID | Foreign key → Target | -| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | +| Field | Type | Description | +| ------------- | -------------- | ------------------------------------------------------------------------------------------------ | +| `id` | UUID | Primary key | +| `event_id` | UUID | Foreign key → Event | +| `target_id` | UUID | Foreign key → Target | +| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | +| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) | +| `replay` | boolean | Whether the delivery was created by **Replay** | -**Relations:** Belongs to Event. Belongs to Target. Has many -DeliveryResults. +**Relations:** Belongs to Event. Belongs to Target. Has many DeliveryResults. **Delivery statuses:** - **`pending`** — Created but not yet attempted. - **`retrying`** — At least one attempt failed; more attempts remain. -- **`delivered`** — Successfully delivered (at least one attempt - succeeded). +- **`delivered`** — Successfully delivered (at least one attempt succeeded). - **`failed`** — All retry attempts exhausted without success. -**Replay.** A `delivered` or `failed` delivery is finished as far as -the engine is concerned, but the event is still stored, so the event -log offers a per-delivery **Replay** action for it. Replay creates a -NEW `pending` delivery for the same event and target and hands it to -the engine on the ordinary path — same retries, same SSRF guard, same -circuit breaker as a first attempt. It never touches the delivery it -repeats: that row's status, timestamps and recorded attempts stand as -the record of what happened. +**Replay.** A `delivered` or `failed` delivery is finished as far as the engine +is concerned, but the event is still stored, so the event log offers a +per-delivery **Replay** action for it. Replay creates a NEW `pending` delivery +for the same event and target and hands it to the engine on the ordinary path — +same retries, same SSRF guard, same circuit breaker as a first attempt. It never +touches the delivery it repeats: that row's status, timestamps and recorded +attempts stand as the record of what happened. The new delivery records +`replay`, and the event log and the event's page label it a replay. -What is re-sent is the stored event body, against the target's -configuration **as it stands now** — the point of a replay is to -deliver where the destination has since been fixed. A target that has -been deleted or deactivated therefore refuses the replay with a -message on the event log rather than delivering from stale -configuration, and a replay is refused while an earlier one for the -same event and target is still pending or retrying. +What is re-sent is the stored event body, against the target's configuration +**as it stands now** — the point of a replay is to deliver where the destination +has since been fixed. A target that has been deleted or deactivated therefore +refuses the replay with a message on the event log rather than delivering from +stale configuration, and a replay is refused while an earlier one for the same +event and target is still pending or retrying. A delivery whose target has been +deleted shows no **Replay** action at all: recreating the target makes a new one +that the old delivery does not name, so **Resubmit** is how that event reaches +the webhook's currently active targets. -**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one -EVENT. The event log offers a per-event **Resubmit** action that stores -a NEW event copying the stored one's `method`, `headers`, `body` and -`content_type` verbatim, then fans it out to the webhook's currently -**active** targets — resolved fresh by the same query the receiver -uses, so a target created long after the original event arrived -receives it. That is the difference that matters: a target added to -test a backend under development has no prior delivery, so there is -nothing to replay to it, while a resubmit reaches it like any other -active target. Inactive targets are skipped, exactly as the receiver -skips them. +**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT. +The event log offers a per-event **Resubmit** action that stores a NEW event +copying the stored one's `method`, `raw_query`, `headers`, `body` and +`content_type` verbatim, then fans it out to the webhook's currently **active** +targets — resolved fresh by the same query the receiver uses, so a target +created long after the original event arrived receives it. That is the +difference that matters: a target added to test a backend under development has +no prior delivery, so there is nothing to replay to it, while a resubmit reaches +it like any other active target. Inactive targets are skipped, exactly as the +receiver skips them. -The new event is a first-class event in the log with its own -deliveries, not a marker on the one it came from, and the original's -deliveries are left untouched. It records `resubmitted_from_id`, and -the event log shows the relationship both ways, so a captured event -fired twenty times at a backend stays traceable. Resubmitting the same -event repeatedly is supported and is the point of the action — there is -no in-flight refusal; the route's own rate limit is what bounds it. +The new event is a first-class event in the log with its own deliveries, not a +marker on the one it came from, and the original's deliveries are left +untouched. It records `resubmitted_from_id`, and the event log shows the +relationship both ways, so a captured event fired twenty times at a backend +stays traceable. Resubmitting the same event repeatedly is supported and is the +point of the action — there is no in-flight refusal; the route's own rate limit +is what bounds it. #### DeliveryResult -The result of a single delivery attempt. Every attempt (including -retries) is individually logged for full observability. +The result of a single delivery attempt. Every attempt (including retries) is +individually logged for full observability. -| Field | Type | Description | -| --------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `delivery_id` | UUID | Foreign key → Delivery | -| `attempt_num` | integer | Attempt number (1-based) | -| `success` | boolean | Whether this attempt succeeded | +| Field | Type | Description | +| --------------- | ------- | ----------------------------------------- | +| `id` | UUID | Primary key | +| `delivery_id` | UUID | Foreign key → Delivery | +| `attempt_num` | integer | Attempt number (1-based) | +| `success` | boolean | Whether this attempt succeeded | | `status_code` | integer | HTTP response status code (if applicable) | -| `response_body` | text | Response body (if applicable) | -| `error` | string | Error message (on failure) | -| `duration` | integer | Request duration in milliseconds | +| `response_body` | text | Response body (if applicable) | +| `error` | string | Error message (on failure) | +| `duration` | integer | Request duration in milliseconds | + +A `database` or `log` target sends no HTTP request, so in the event log and on +the event's page its attempts show no status: a successful one reads "archived" +or "written to the log". + +The event log and the event's page show when each attempt was recorded and when +each delivery was created, as the recent events list shows when an event +arrived: how long ago, with the full UTC time on hover. **Relations:** Belongs to Delivery. +#### EventTotals, TargetTotals and EntrypointTotals + +Running counts in each event database, read by the statistics pane at the top of +the webhook page and by the webhook list, and each entrypoint's last event, read +by the webhook page's entrypoint list. `EventTotals` is one row: + +| Field | Type | Description | +| ---------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `events` | integer | Events ever stored, resubmitted copies included | +| `events_removed` | integer | Events retention has deleted | +| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is | + +`TargetTotals` is one row per target, created by the first delivery to it: + +| Field | Type | Description | +| -------------------- | ------- | ----------------------------------------------- | +| `target_id` | UUID | The target (primary key) | +| `deliveries` | integer | Deliveries to it ever created, replays included | +| `delivered` | integer | Of those, how many became `delivered` | +| `failed` | integer | Of those, how many became `failed` | +| `deliveries_removed` | integer | Its deliveries retention has deleted | +| `failed_removed` | integer | Its failed deliveries retention has deleted | + +`EntrypointTotals` is one row per entrypoint, created by the first event that +arrives on its URL: + +| Field | Type | Description | +| --------------- | --------- | ------------------------------------------------------------------------------------------------------- | +| `entrypoint_id` | UUID | The entrypoint (primary key) | +| `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention | + +Each count changes in the transaction that writes or deletes the rows it counts, +and each `last_event_at` in the transaction that stores the event. The pane's +lifetime events are `events`, and its lifetime deliveries and failures are +`deliveries` and `failed` summed over the targets; each figure within retention +is the same less what retention removed, so neither needs the rows themselves. +Its last event is `last_event_at` in `EventTotals`, so it still shows once +retention has removed every event; each entrypoint's last event, from +`EntrypointTotals`, does too. Its last-10-minutes and last-24-hours figures are +counted from the `events` and `deliveries` indexes over just that window, the +deliveries in one query grouped by target. Its failure percentage for a window +is the deliveries that became `failed` in it out of all that became `delivered` +or `failed` in it, and a dash when none did. + +The webhook list at `/hooks` shows three of the pane's figures for each webhook: +its events within retention and its last event, both from `EventTotals`, and its +deliveries that failed in the last 24 hours, counted with the pane's query. It +opens each webhook's event database once (the handle stays open) and runs those +two reads there, so its cost grows with the number of webhooks and, for each, +with the deliveries that finished in the last 24 hours, never with the events +stored. + +The target list on the webhook page shows, for each target, its `delivered` and +`failed` totals, which retention does not reduce, and its deliveries that became +`delivered` and `failed` in the last 24 hours, counted with the pane's query. +Deliveries still `pending` or `retrying` count in neither. + #### Event-tier indexes -These indexes on the per-webhook event databases are declared in the model -tags, so `AutoMigrate` creates them on a fresh and on an existing database: +These indexes on the per-webhook event databases are created by +`internal/db/migrations/events/001_schema.sql`, and declared in the model tags +to match: -| Table | Columns | Serves | -| ------------------ | --------------------------- | ------ | -| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status | -| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events | -| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | -| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age | -| `events` | `created_at` | Retention's delete of the expired events themselves | +| Table | Columns | Serves | +| ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | +| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | +| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | +| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | +| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | +| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | +| `events` | `created_at` | Retention, which selects expired events by age | -GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's -deletes leave it out, but their lookups of expired rows keep it. SQLite keeps -no statistics on these tables, and without them it rates the `deleted_at` -index, which every live row matches, above an index on a column matched -against several values or compared with `<`. So every index but the last also -covers `deleted_at`. It comes second, so that retention's deletes can use the -index without it, except in `events`, where `created_at` is compared with `<` -and SQLite narrows by a `<` only on the last column it uses. +GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves +it out. SQLite keeps no statistics on these tables, and without them it rates +the `deleted_at` index, which every live row matches, above an index on a column +matched against several values or compared with a range. So every index but the +last also covers `deleted_at`. It comes second in the `event_id` and +`delivery_id` indexes, so that retention can use them without it. The event +log's count, the one query on the `resubmitted_from_id` index, always carries +`deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one +query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL` +leaving out resubmitted copies and `created_at` last because it compares it with +a range (`>=`). In the statistics' `events` index `deleted_at` comes first, +because they compare `created_at` with a range (`>=`) and SQLite narrows by a +range only on the last column it uses. #### Common Fields -Every entity except `Setting` includes these fields from `BaseModel`. -`Setting` is a bare key-value row with no `id`, no timestamps and no -soft delete: +Every entity except `Setting`, `EventTotals`, `TargetTotals` and +`EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare +key-value row with no `id`, no timestamps and no soft delete. Of the three +totals tables, `event_totals` holds counts and `last_event_at`, keyed by a +numeric `id`; `target_totals` holds counts, keyed by `target_id`; and +`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`: -| Field | Type | Description | -| ------------ | --------- | ----------- | -| `id` | UUID | Auto-generated UUIDv4 primary key | -| `created_at` | timestamp | Record creation time | -| `updated_at` | timestamp | Last modification time | +| Field | Type | Description | +| ------------ | --------- | --------------------------------------------------- | +| `id` | UUID | Auto-generated UUIDv4 primary key | +| `created_at` | timestamp | Record creation time | +| `updated_at` | timestamp | Last modification time | | `deleted_at` | timestamp | Soft-delete timestamp (nullable; GORM soft deletes) | ### Database Architecture #### Per-Webhook Event Databases -webhooker uses **separate SQLite database files**: a main application -database for configuration data and per-webhook databases for event -storage. All database files live in the `DATA_DIR` directory. +webhooker uses **separate SQLite database files**: a main application database +for configuration data and per-webhook databases for event storage. All database +files live in the `DATA_DIR` directory. -Every one of them is created `0600`, and so is each `-wal` and `-shm` -sidecar. See -[Running with Docker](#running-with-docker) for what that does and -does not protect. +Every one of them is created `0600`, and so is each `-wal` and `-shm` sidecar. +See [Running with Docker](#running-with-docker) for what that does and does not +protect. -**Main Application Database** (`{DATA_DIR}/webhooker.db`) — stores -configuration and application state: +**Main Application Database** (`{DATA_DIR}/webhooker.db`) — stores configuration +and application state: -- **Settings** — auto-managed key-value config (e.g. session encryption - key) +- **Settings** — auto-managed key-value config (e.g. session encryption key) - **Users** — accounts and Argon2id password hashes - **Webhooks** — webhook configurations - **Entrypoints** — receiver URL definitions - **Targets** — delivery destination configurations - **APIKeys** — programmatic access credentials -On first startup the main database is auto-migrated, a session -encryption key is generated and stored, and an `admin` user is created. +On first startup the main database is created from its SQL files (see +[Schema](#schema)), a session encryption key is generated and stored, and an +`admin` user is created. -**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) -— each webhook gets its own dedicated SQLite file containing: +**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each +webhook gets its own dedicated SQLite file containing: - **Events** — captured incoming webhook payloads - **Deliveries** — event-to-target pairings and their status - **DeliveryResults** — individual delivery attempt logs +- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running counts of + the above, the deliveries per target, and each entrypoint's last event, kept + through retention -Per-webhook databases are created automatically when a webhook is -created (and lazily on first access for webhooks that predate this -feature). They are managed by the `WebhookDBManager` component, which +Per-webhook databases are created automatically when a webhook is created, from +the SQL files in `internal/db/migrations/events/`. They hold no copy of the main +database's tables. They are managed by the `WebhookDBManager` component, which handles connection pooling, lazy opening, migrations, and cleanup. +A per-webhook database that is missing or zero-length later means its webhook's +events and pending deliveries are gone. The next time it is opened, an empty one +is created in its place, so the webhook keeps receiving, and +`created a new, empty database` is logged at `WARN` with the file's path. Every +webhook's database is opened when the service starts, so this appears at the +latest at the first start after the file was lost. A file there that SQLite +cannot open fails that webhook alone, with an `ERROR` naming the webhook on +every access and a 500 to its senders, so one damaged file does not stop the +others. + This separation provides: -- **Isolation** — a high-volume webhook won't cause lock contention or - journal growth affecting the main application or other webhooks. -- **Independent lifecycle** — event databases can be independently - backed up, archived, rotated, or size-limited without impacting the - application. -- **Clean deletion** — removing a webhook and all its history is as - simple as deleting one file. Configuration is soft-deleted in the main - DB; the event database file is hard-deleted (permanently removed). +- **Isolation** — a high-volume webhook won't cause lock contention or journal + growth affecting the main application or other webhooks. +- **Independent lifecycle** — event databases can be independently backed up, + archived, rotated, or size-limited without impacting the application. +- **Clean deletion** — removing a webhook and all its history is as simple as + deleting one file. Configuration is soft-deleted in the main DB; the event + database file is hard-deleted (permanently removed). - **Per-webhook retention** — the `retention_days` field on each webhook - controls automatic cleanup of old events in that webhook's database - only, or disables cleanup entirely when set to `0` (retain forever). -- **Performance** — each webhook's database has its own page cache and - its own lock, so concurrent event ingestion across webhooks won't - contend. Every database — main, per-webhook, and archive — is opened - through one code path (`internal/database/sqlite_open.go`) in WAL - journal mode, with a 10-second busy timeout, `BEGIN IMMEDIATE` - transactions, and a bounded connection pool. Under WAL a reader never - blocks a writer, so an operator reading a database does not stall - event ingestion into it. + controls automatic cleanup of old events in that webhook's database only, or + disables cleanup entirely when set to `0` (retain forever). +- **Performance** — each webhook's database has its own page cache and its own + lock, so concurrent event ingestion across webhooks won't contend. Every + database — main, per-webhook, and archive — is opened through one code path + (`internal/database/sqlite_open.go`) in WAL journal mode, with a 10-second + busy timeout, `BEGIN IMMEDIATE` transactions, and a bounded connection pool. + Under WAL a reader never blocks a writer, so an operator reading a database + does not stall event ingestion into it. -The **database target type** builds on this architecture to provide -long-term archiving, separate from the per-webhook event database (which -may prune events under its own retention). Delivering to a database -target writes the full event — body, headers, method, content type, and -webhook/entrypoint/event identifiers — as a row into a dedicated archive -database, `archive-{webhookID}.db`, stored under the data directory -beside the event database. After each write the archive handle is closed -and reopened, debounced to at most once per second, so an operator can -move the archive file away for offline archiving without stopping the -service; a moved or removed archive file is recreated automatically on -the next write. An optional `expiry` in the target's config JSON (e.g. -`{"expiry":"720h"}`) is validated when the target is created — the -default (unset or the literal `never`) keeps rows forever — and rows -older than the expiry are pruned each time the archive is (re)opened. An -archive write failure is never silent success: the delivery records a -failed attempt with the error and is marked failed. +The **database target type** builds on this architecture to provide long-term +archiving, separate from the per-webhook event database (which may prune events +under its own retention). Delivering to a database target writes the full event +— body, headers, method, content type, and webhook/entrypoint/event identifiers +— as a row into the target's own archive database, +`archive-{webhook_name}-{target_name}-{target_uuid}.db`, stored under the data +directory beside the event database. Each `database` target has its own archive +file, even when one webhook has several. -Because reopens only happen on writes, an archive belonging to a webhook -that has stopped receiving events would never be pruned. A background -**archive sweeper** closes that gap: on the same interval as the event -retention reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive -whose database target declares a positive expiry, whether or not the -webhook is still receiving traffic. The sweep never creates an archive — -a webhook whose archive file does not yet exist is skipped, not -initialised — it takes the same per-webhook lock the write path uses, so -it can never interleave with a write, and it leaves the archive closed -afterwards so the move-the-file-away workflow keeps working. Archives -with no expiry, or the expiry `never`, are not touched by the sweep at +Both names are made safe for a file name the same way: lowercased, ASCII letters +and digits kept, every other run of characters turned into a single `-`, no `-` +at either end, cut to 40 characters, and `unnamed` when nothing is left. The +target UUID keeps the file name unique. A webhook named `Orders (EU)` with a +target named `Long-term archive` archives into +`archive-orders-eu-long-term-archive-{target_uuid}.db`. + +An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`) +is `none`, the default, which keeps the one file, or `monthly`, `daily` or +`hourly`. A target that rotates writes each event to a file named for the period +of the event's receive time, in UTC, put before the `.db`: +`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, +`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly +its period's events, and the first event of a new period starts the next file, +so a finished period's file can be moved away like any archive. A changed +rotation applies from the next event: the files already written keep their names +and stay, pruned, shown and downloaded with the rest, since every file named for +the target is its archive, whichever rotation wrote it. + +Renaming the webhook or the target renames every one of the target's files, each +keeping its period, under the same lock the archive writes and the archive +sweeper take. Webhook edits, target edits and target creation run one at a time, +so no edit can rename the files between another's rename and save, and the names +on disk match the UI. A rename never replaces a file: if one already has a new +name, the edit is refused with an error naming that file, nothing is moved, and +the stored name stays. If the archive is not there (the operator moved it away), +the rename is not an error, and the next write creates the file under the new +name. + +The file is moved just before the new name is saved. If the process stops +between the two, the archive is left under the new name while the UI still shows +the old one, and the next delivery starts a second archive under the name shown. +To bring them back together, stop the service before moving anything, and move +each archive as its `.db` together with any `-wal` and `-shm` beside it, since +the `-wal` can hold rows that are not yet in the `.db`. If no file has the name +shown, move the archive under the new name back to it. If a second archive +already has the name shown, move the archive under the new name out of the data +directory instead and keep it as you would any archive moved away. Then start +the service again. + +After each write the archive handle is closed and reopened, debounced to at most +once per second, so an operator can move the archive file away for offline +archiving without stopping the service; a moved or removed archive file is +recreated automatically on the next write, from the SQL files in +`internal/db/migrations/archive/`. A zero-length archive file is written to as a +new archive: SQLite opens it as an empty database, so it holds nothing to lose. +An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is +validated when the target is created — the default (unset or the literal +`never`) keeps rows forever — and rows older than the expiry are pruned each +time the archive is (re)opened. An archive write failure is never silent +success: the delivery records a failed attempt with the error and is marked +failed. + +Because reopens only happen on writes, an archive whose target has stopped +receiving events would never be pruned. A background **archive sweeper** closes +that gap: on the same interval as the event retention reaper +(`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose database target +declares a positive expiry, whether or not the target is still receiving +traffic. The sweep never creates an archive — a target whose archive file does +not yet exist is skipped, not initialised — it takes the same per-target lock +the write path uses, so it can never interleave with a write, and it leaves the +archive closed afterwards so the move-the-file-away workflow keeps working. +Archives with no expiry, or the expiry `never`, are not touched by the sweep at all. -Note that a webhook has one archive file but may carry more than one -`database` target, each with its own `expiry`. The shortest expiry -configured on any of them therefore governs the whole archive, and the -sweep applies it whether or not the webhook is still receiving events. -Configure a single `database` target per webhook unless you intend that. +For a target with several files, the write path prunes only the file it writes +to, and the sweep prunes every one of them, taking the target's lock for one +file at a time, so a write to the target waits for at most one file's prune. A +file that is gone by the time the sweep reaches it is skipped. A file named for +a period that the sweep leaves empty is deleted, with any `-wal` and `-shm` +beside it; the file without a period is kept even when empty, as it always has +been. -Deleting a webhook releases its archive: the delivery engine's cached -archive writer is dropped and its file handle closed, so nothing lingers -after the webhook is gone. The archive **file itself is deliberately -left on disk**. Unlike the event database — per-webhook working storage -that is hard-deleted with the webhook — an archive is long-term storage -an operator may still want to keep or move away for offline retention, -and destroying it as a side effect of deleting a webhook would be -unrecoverable. Removing `archive-{webhookID}.db` is the operator's call. -Deleting a webhook's last `database` target releases the writer the same -way, and for the same reason leaves the file alone. +Because each `database` target has its own archive file, a target's `expiry` +governs only its own archive. Two `database` targets on one webhook with +different expiries keep two archives, each pruned on its own schedule. -The **Slack target type** sends webhook events as formatted messages to -any Slack-compatible incoming webhook URL (works with Slack, Mattermost, -and other compatible services). Each message includes event metadata -(HTTP method, content type, timestamp, body size) and the payload -pretty-printed in a code block. JSON payloads are automatically -formatted with indentation for readability; non-JSON payloads are shown -as raw text. Large payloads are truncated to keep messages reasonable. -Config stores `webhookUrl` — the Slack/Mattermost incoming webhook +The webhook page shows, for each `database` target, the name of the archive file +an event received now would go to, the size on disk of all the target's archive +files together, how many there are when there is more than one, and when the +latest of them was last written. The size counts each `.db` and its `-wal` +together, and the last write is the latest of their modification times, since a +write lands in the `-wal` first. All are read from the files' metadata; the +archive is never opened. While the named file does not exist — before the first +write, before the first event of a new period, and after the file has been moved +away — the page shows `not created yet` beside the name. + +Each `database` target on the webhook page has a **Download** button, which +returns its archive as one gzipped JSON file, +`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the names +made safe as above and the time in UTC. The file holds one object: `webhook` and +`target`, each an `id` and a `name`; `exported_at`; and `archived_events`, one +object per archived row with every column, keyed by column name. The rows come +from every one of the target's files: the file without a period first, then the +others in the order of their periods, oldest first, and each row from a file +named for a period has that `period` beside its columns. A body that is not +valid UTF-8 is written in base64, with `"body_encoding": "base64"` beside it. An +archive that does not exist yet, or was moved away, downloads with an empty +`archived_events`; the download never creates a file. + +The download streams: each row is read and written out compressed before the +next is read, so neither the archive nor the JSON is held in memory. When it +starts it lists the target's files by the stored names, under the lock that +webhook edits, target edits and target creation hold, and lets go. It then opens +one file at a time, only when its rows are about to be written out, and closes +it before it opens the next, so it never has more than one of the target's files +open. To open each, it takes the lock again just long enough to find the file by +its period under the names stored then, so a rename during the download loses no +file; a file that is gone by then, emptied by the sweep or moved away, is +skipped. Each file is read on a connection of its own inside one read-only +transaction, so it is written out as it stood when it was opened, and archive +writes go on meanwhile, since under WAL a reader never blocks a writer. Until +the open file is closed its `-wal` cannot be checkpointed past what the download +reads, so a long download lets that `-wal` grow. + +Deleting a webhook releases its archives: the delivery engine's cached archive +writers are dropped and their file handles closed, so nothing lingers after the +webhook is gone. The archive **files themselves are deliberately left on disk**. +Unlike the event database — per-webhook working storage that is hard-deleted +with the webhook — an archive is long-term storage an operator may still want to +keep or move away for offline retention, and destroying it as a side effect of +deleting a webhook would be unrecoverable. Removing an `archive-….db` is the +operator's call. Deleting a `database` target releases its writer the same way, +and for the same reason leaves its file alone. + +The **Slack target type** sends webhook events as formatted messages to any +Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other +compatible services). Each message includes event metadata (HTTP method, content +type, timestamp, body size) and the payload pretty-printed in a code block. JSON +payloads are automatically formatted with indentation for readability; non-JSON +payloads are shown as raw text. Large payloads are truncated to keep messages +reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook endpoint. That is the JSON key; the error text for a missing one reads `webhook_url is required`, which is the message, not the key. The database uses the -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at -runtime, though CGO is required at build time due to the transitive -`mattn/go-sqlite3` dependency from `gorm.io/driver/sqlite`. +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, +though CGO is required at build time due to the transitive `mattn/go-sqlite3` +dependency from `gorm.io/driver/sqlite`. + +#### Schema + +Each kind of database file has its schema in SQL files built into the binary, +one directory per kind under `internal/db/migrations/`. In each, +`000_migration.sql` creates the `schema_migrations` table, which records the +number of every file the database has had, and `001_schema.sql` holds the whole +schema. Opening a database runs, in one transaction, every file in its directory +that `schema_migrations` does not list: + +- `main/` for the main database, on connect — settings, users, API keys, + webhooks, entrypoints and targets +- `events/` for each event database, when it is lazily opened — events, + deliveries, delivery results and their running totals +- `archive/` for each archive database, on every open and reopen ### Request Flow ``` External Service │ - │ POST /webhook/{uuid} + │ POST /h/{uuid} ▼ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ chi Router │────►│ Middleware │────►│ Webhook │ @@ -1935,88 +2173,85 @@ External Service ### Bounded Worker Pool -The delivery engine uses a **fixed-size worker pool** (default: 10 -workers) to process all deliveries. At most N deliveries are in-flight -at any time, preventing goroutine explosions regardless of queue depth. +The delivery engine uses a **fixed-size worker pool** (default: 10 workers) to +process all deliveries. At most N deliveries are in-flight at any time, +preventing goroutine explosions regardless of queue depth. **Architecture:** -- **Channels as queues:** Two buffered channels serve as bounded queues: - a delivery channel (new tasks from the webhook handler) and a retry - channel (tasks from backoff timers). Both are buffered to 10,000. -- **Fan-out via channel, not goroutines:** When an event arrives with - multiple targets, each `delivery.Task` is sent to the delivery channel. - Workers pick them up and process them — no goroutine-per-target. -- **Worker goroutines:** A fixed number of worker goroutines select from - both channels. Each worker processes one task at a time, then picks up - the next. Workers are the ONLY goroutines doing actual HTTP delivery. -- **Retry backpressure with DB fallback:** When a retry timer fires and - the retry channel is full, the timer is dropped — the delivery stays - in `retrying` status in the database. A periodic sweep (every 60s) - scans for these "orphaned" retries and re-queues them. No blocked - goroutines, no unbounded timer chains. -- **Bounded concurrency:** At most N deliveries (N = number of workers) - are in-flight simultaneously. Even if a circuit breaker is open for - hours and thousands of retries queue up in the channels, the workers - drain them at a controlled rate when the circuit closes. +- **Channels as queues:** Two buffered channels serve as bounded queues: a + delivery channel (new tasks from the webhook handler) and a retry channel + (tasks from backoff timers). Both are buffered to 10,000. +- **Fan-out via channel, not goroutines:** When an event arrives with multiple + targets, each `delivery.Task` is sent to the delivery channel. Workers pick + them up and process them — no goroutine-per-target. +- **Worker goroutines:** A fixed number of worker goroutines select from both + channels. Each worker processes one task at a time, then picks up the next. + Workers are the ONLY goroutines doing actual HTTP delivery. +- **Retry backpressure with DB fallback:** When a retry timer fires and the + retry channel is full, the timer is dropped — the delivery stays in `retrying` + status in the database. A periodic sweep (every 60s) scans for these + "orphaned" retries and re-queues them. No blocked goroutines, no unbounded + timer chains. +- **Bounded concurrency:** At most N deliveries (N = number of workers) are + in-flight simultaneously. Even if a circuit breaker is open for hours and + thousands of retries queue up in the channels, the workers drain them at a + controlled rate when the circuit closes. This means: -- **No goroutine explosion** — even with 10,000 queued retries, only - N worker goroutines exist. -- **Natural backpressure** — if workers are busy, new tasks wait in the - channel buffer rather than spawning more goroutines. -- **Independent results** — each worker records its own delivery result - in the per-webhook database without coordination. -- **Graceful shutdown** — cancel the context, workers finish their - current task and exit. The stop hook waits for the pool via - `lifecycle.WaitForShutdown`, which bounds that wait by fx's stop - timeout rather than blocking forever on a wedged worker. On timeout - it logs at `ERROR` and returns an error, and the goroutines that - did not finish are still running — an unclean shutdown is reported - rather than hidden. +- **No goroutine explosion** — even with 10,000 queued retries, only N worker + goroutines exist. +- **Natural backpressure** — if workers are busy, new tasks wait in the channel + buffer rather than spawning more goroutines. +- **Independent results** — each worker records its own delivery result in the + per-webhook database without coordination. +- **Graceful shutdown** — cancel the context, workers finish their current task + and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`, + which bounds that wait by fx's stop timeout rather than blocking forever on a + wedged worker. On timeout it logs at `ERROR` and returns an error, and the + goroutines that did not finish are still running — an unclean shutdown is + reported rather than hidden. **Recovery paths:** 1. **Startup recovery:** When the engine starts, it scans all per-webhook - databases for `pending` and `retrying` deliveries. Pending deliveries - are sent to the delivery channel; retrying deliveries get backoff - timers scheduled. -2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the - engine scans for `retrying` deliveries whose backoff period has - elapsed. This catches "orphaned" retries — ones whose in-memory timer - was dropped because the retry channel was full. The database is the - durable fallback that ensures no retry is permanently lost, even under - extreme backpressure. + databases for `pending` and `retrying` deliveries. Pending deliveries are + sent to the delivery channel; retrying deliveries get backoff timers + scheduled. +2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the engine + scans for `retrying` deliveries whose backoff period has elapsed. This + catches "orphaned" retries — ones whose in-memory timer was dropped because + the retry channel was full. The database is the durable fallback that ensures + no retry is permanently lost, even under extreme backpressure. -**Changing a target's type does not migrate in-flight deliveries.** Only -`http` and `slack` targets own durable retries; `database` and `log` -targets are fire-and-forget and never produce a `retrying` delivery. If a -target's `type` is edited from a retrying type to a non-retrying (or -unknown) one while one of its deliveries is still `retrying`, both -recovery paths above terminally mark that delivery `failed` and record a -`DeliveryResult` naming the current target type as the reason, logging it -at warn level. The delivery is not re-dispatched under the new type — the -operator never asked for that delivery — and the event itself remains -stored in the per-webhook event database. Nothing is lost: the delivery -is `failed`, which is terminal, so the event log offers **Replay** on -it to re-send it to that target under the new type, and **Resubmit** on -the event to re-inject it to every currently active target. +**Changing a target's type does not migrate in-flight deliveries.** Only `http` +and `slack` targets own durable retries; `database` and `log` targets are +fire-and-forget and never produce a `retrying` delivery. If a target's `type` is +edited from a retrying type to a non-retrying (or unknown) one while one of its +deliveries is still `retrying`, both recovery paths above terminally mark that +delivery `failed` and record a `DeliveryResult` naming the current target type +as the reason, logging it at warn level. The delivery is not re-dispatched under +the new type — the operator never asked for that delivery — and the event itself +remains stored in the per-webhook event database. Nothing is lost: the delivery +is `failed`, which is terminal, so the event log offers **Replay** on it to +re-send it to that target under the new type, and **Resubmit** on the event to +re-inject it to every currently active target. ### Circuit Breaker (HTTP and Slack Targets with Retries) `http` and `slack` targets with `max_retries` > 0 are protected by a -**per-target circuit breaker** that prevents hammering a down target -with repeated failed delivery attempts. The circuit breaker is -in-memory only and resets on restart (which is fine — startup recovery -rescans the database anyway). +**per-target circuit breaker** that prevents hammering a down target with +repeated failed delivery attempts. The circuit breaker is in-memory only and +resets on restart (which is fine — startup recovery rescans the database +anyway). **States:** -| State | Behavior | -| ----------- | -------- | -| **Closed** | Normal operation. Deliveries flow through. Consecutive failures are counted. | -| **Open** | Target appears down. Deliveries are skipped and rescheduled for after the cooldown. | +| State | Behavior | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Closed** | Normal operation. Deliveries flow through. Consecutive failures are counted. | +| **Open** | Target appears down. Deliveries are skipped and rescheduled for after the cooldown. | | **Half-Open** | Cooldown expired. One probe delivery is allowed to test if the target has recovered. Other deliveries are rescheduled for one whole cooldown later. | **Transitions:** @@ -2046,730 +2281,707 @@ rescans the database anyway). - **Cooldown:** 30 seconds in open state before probing **Scope:** Circuit breakers apply to **`http` and `slack` targets with -`max_retries` > 0**. The Slack target is built on the same HTTP core -and hands its own `max_retries` to the same retry path, so it gets a -breaker with the same 5-failure / 30-second defaults. Fire-and-forget -targets of either type (`max_retries` == 0), database targets (local -operations), and log targets (stdout) do not use circuit breakers. +`max_retries` > 0**. The Slack target is built on the same HTTP core and hands +its own `max_retries` to the same retry path, so it gets a breaker with the same +5-failure / 30-second defaults. Fire-and-forget targets of either type +(`max_retries` == 0), database targets (local operations), and log targets +(stdout) do not use circuit breakers. -When a circuit is open and a new delivery arrives, the engine marks the -delivery as `retrying` and schedules a retry timer for after the -remaining cooldown period. This ensures no deliveries are lost — they're -just delayed until the target is healthy again. A delivery already in -`retrying` keeps that status without another database write each time -the breaker turns it away. +When a circuit is open and a new delivery arrives, the engine marks the delivery +as `retrying` and schedules a retry timer for after the remaining cooldown +period. This ensures no deliveries are lost — they're just delayed until the +target is healthy again. A delivery already in `retrying` keeps that status +without another database write each time the breaker turns it away. + +While a target's breaker is open, the target's row on the webhook page says its +deliveries are paused until the cooldown ends, in UTC and as a time from now. +Each of its `retrying` deliveries shows as waiting in the event log and on the +event's page, with the earliest it can be tried next: the later of the +cooldown's end and the end of its own backoff after its last attempt. It is only +the earliest: when the cooldown ends, one of the target's waiting deliveries is +sent to test it while the others wait at least one more cooldown, as the row +also says. A time not on the current UTC day is shown with its date. While the +breaker is half-open, the row says instead that deliveries are held while one +delivery tests whether the target has recovered, with no time, and the target's +deliveries show their plain status, since any of them may be the one being sent. ### Metrics `/metrics` serves one Prometheus registry behind basic auth (see -[Infrastructure Endpoints](#infrastructure-endpoints)). Alongside the -inbound HTTP metrics recorded by the middleware, it exposes the -delivery pipeline — the part of the service that can be failing while -the receive side looks perfectly healthy, because it is: events are -arriving and being stored, they are just not getting anywhere. +[Infrastructure Endpoints](#infrastructure-endpoints)). Alongside the inbound +HTTP metrics recorded by the middleware, it exposes the delivery pipeline — the +part of the service that can be failing while the receive side looks perfectly +healthy, because it is: events are arriving and being stored, they are just not +getting anywhere. -| Metric | Type | Meaning | -| ------ | ---- | ------- | -| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | -| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | -| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | -| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | -| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | -| `webhooker_delivery_replays_total` | counter | Deliveries an operator replayed from the event log. A replay runs the ordinary engine path, so it also moves the attempt, outcome and duration series; this is the only one that separates it from ordinary traffic | -| `webhooker_events_resubmitted_total` | counter | Stored events an operator re-injected from the event log. The new event also moves `webhooker_events_received_total`, since it is a stored event the delivery side is compared against; this counter is what separates the two. Unlabelled: the target types it fans out to belong to the delivery series | -| `webhooker_delivery_duration_seconds` | histogram | Wall time of a single dispatched delivery attempt, the same duration the attempt's `DeliveryResult` records | -| `webhooker_deliveries_pending` | gauge | Deliveries currently in `pending` | -| `webhooker_deliveries_retrying` | gauge | Deliveries currently in `retrying` | -| `webhooker_circuit_breakers_open` | gauge | Circuit breakers currently open | +| Metric | Type | Meaning | +| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | +| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | +| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | +| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | +| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | +| `webhooker_delivery_replays_total` | counter | Deliveries an operator replayed from the event log. A replay runs the ordinary engine path, so it also moves the attempt, outcome and duration series; this is the only one that separates it from ordinary traffic | +| `webhooker_events_resubmitted_total` | counter | Stored events an operator re-injected from the event log. The new event also moves `webhooker_events_received_total`, since it is a stored event the delivery side is compared against; this counter is what separates the two. Unlabelled: the target types it fans out to belong to the delivery series | +| `webhooker_delivery_duration_seconds` | histogram | Wall time of a single dispatched delivery attempt, the same duration the attempt's `DeliveryResult` records | +| `webhooker_deliveries_pending` | gauge | Deliveries currently in `pending` | +| `webhooker_deliveries_retrying` | gauge | Deliveries currently in `retrying` | +| `webhooker_circuit_breakers_open` | gauge | Circuit breakers currently open | -Every delivery metric carries exactly one label, `target_type`, and -cardinality is the whole reason for that restriction. The two -event-level counters carry no label at all — an event is not the -property of any one target type. A target type is -one of four compile-time constants, so the label domain is bounded by -construction; a value outside that set collapses to `unknown` rather -than minting a series of its own. Target ids, event ids and entrypoint -ids are deliberately not labels: they are UUIDs minted per operator -action or per inbound request, a series is never reclaimed once it -exists, and labelling by any of them would make `/metrics` a memory -leak that grows with traffic. +Every delivery metric carries exactly one label, `target_type`, and cardinality +is the whole reason for that restriction. The two event-level counters carry no +label at all — an event is not the property of any one target type. A target +type is one of four compile-time constants, so the label domain is bounded by +construction; a value outside that set collapses to `unknown` rather than +minting a series of its own. Target ids, event ids and entrypoint ids are +deliberately not labels: they are UUIDs minted per operator action or per +inbound request, a series is never reclaimed once it exists, and labelling by +any of them would make `/metrics` a memory leak that grows with traffic. -The two queue-depth gauges are counted out of the databases by a -sampler that runs every 30 seconds for as long as the delivery engine -does, rather than tracked as deltas alongside the status transitions: a -delta would have to be seeded at startup from rows a previous process -wrote, and would drift permanently on any transition that failed to -persist. +The two queue-depth gauges are counted out of the databases by a sampler that +runs every 30 seconds for as long as the delivery engine does, rather than +tracked as deltas alongside the status transitions: a delta would have to be +seeded at startup from rows a previous process wrote, and would drift +permanently on any transition that failed to persist. -Those two gauges also publish an `unknown` series, from startup rather -than on first occurrence. Deliveries queued against a target that has -since been deleted are counted there: that backlog is the one nobody is -watching, so it is the one that must not silently vanish from the -gauge. The outcome counters move only after the status change has been -written, so a transition the database rejected is never reported as an -outcome that happened. +Those two gauges also publish an `unknown` series, from startup rather than on +first occurrence. Deliveries queued against a target that has since been deleted +are counted there: that backlog is the one nobody is watching, so it is the one +that must not silently vanish from the gauge. The outcome counters move only +after the status change has been written, so a transition the database rejected +is never reported as an outcome that happened. #### Inbound HTTP metrics The middleware records three more on the same registry: -| Metric | Type | Labels | -| ------ | ---- | ------ | +| Metric | Type | Labels | +| ------------------------------- | --------- | -------------------------------------- | | `http_request_duration_seconds` | histogram | `service`, `handler`, `method`, `code` | -| `http_response_size_bytes` | histogram | `service`, `handler`, `method`, `code` | -| `http_requests_inflight` | gauge | `service`, `handler` | +| `http_response_size_bytes` | histogram | `service`, `handler`, `method`, `code` | +| `http_requests_inflight` | gauge | `service`, `handler` | -Two of those labels are written once per request from bytes the client -chose, so both are bounded to something this service registers: +Two of those labels are written once per request from bytes the client chose, so +both are bounded to something this service registers: -- `handler` is the chi route pattern — `/webhook/{uuid}`, never the - concrete path. A request matching no route carries `(unmatched)`, - and no entrypoint UUID ever reaches a label. -- `method` is the request method when the router can route it, and - `(unmatched)` otherwise. `net/http` accepts any RFC 9110 token as a - method, so the raw value bounds the label at nothing; the nine chi - matches routes for stay distinguishable, and a token that could only - ever have produced a 405 does not get a series of its own. +- `handler` is the chi route pattern — `/h/{uuid}`, never the concrete path. A + request matching no route carries `(unmatched)`, and no entrypoint UUID ever + reaches a label. +- `method` is the request method when the router can route it, and `(unmatched)` + otherwise. `net/http` accepts any RFC 9110 token as a method, so the raw value + bounds the label at nothing; the nine chi matches routes for stay + distinguishable, and a token that could only ever have produced a 405 does not + get a series of its own. -The other two are not request-controlled: `code` is the status one of -this service's own handlers wrote, and `service` is a fixed empty -string. +The other two are not request-controlled: `code` is the status one of this +service's own handlers wrote, and `service` is a fixed empty string. -`http_requests_inflight` is deliberately aggregate — its `handler` is -always `(all)`, one series counting the requests in flight across the -whole service. The gauge is incremented before routing and decremented -after the handler returns, and the route pattern exists only between -those two moments, so labelling it by pattern would increment one -series and decrement another, leaving every pattern permanently off by -the number of requests it served. +`http_requests_inflight` is deliberately aggregate — its `handler` is always +`(all)`, one series counting the requests in flight across the whole service. +The gauge is incremented before routing and decremented after the handler +returns, and the route pattern exists only between those two moments, so +labelling it by pattern would increment one series and decrement another, +leaving every pattern permanently off by the number of requests it served. ### Rate Limiting -Global blanket rate limiting middleware (e.g., a per-IP throttle shared -with the web UI) **must not** apply to webhook receiver endpoints. -Webhook endpoints receive automated traffic from external services at -unpredictable rates, and blanket limits shared with other routes would -cause legitimate deliveries to be dropped. +Global blanket rate limiting middleware (e.g., a per-IP throttle shared with the +web UI) **must not** apply to webhook receiver endpoints. Webhook endpoints +receive automated traffic from external services at unpredictable rates, and +blanket limits shared with other routes would cause legitimate deliveries to be +dropped. The receiver instead has its own dedicated abuse limit, scoped to the -`/webhook/{uuid}` route only and keyed per client IP per request path +`/h/{uuid}` route only and keyed per client IP per request path (`httprate.KeyByEndpoint`): one misbehaving sender is throttled without -affecting other senders of the same entrypoint or the same sender's -other entrypoints. Keying on the path rather than on the entrypoint -matters — see the aggregate limit below. The limit is -`RECEIVER_RATE_LIMIT` requests per minute (default 120, generous for -legitimate webhook senders). Requests over the limit receive HTTP 429 -with a `Retry-After` header. A set-but-invalid `RECEIVER_RATE_LIMIT` -value aborts startup rather than silently falling back to the default. +affecting other senders of the same entrypoint or the same sender's other +entrypoints. Keying on the path rather than on the entrypoint matters — see the +aggregate limit below. The limit is `RECEIVER_RATE_LIMIT` requests per minute +(default 120, generous for legitimate webhook senders). Requests over the limit +receive HTTP 429 with a `Retry-After` header. A set-but-invalid +`RECEIVER_RATE_LIMIT` value aborts startup rather than silently falling back to +the default. -A second limit sits in front of that one, keyed on the client IP alone -and covering the whole route at ten times `RECEIVER_RATE_LIMIT` requests -per minute (default 1200). The per-entrypoint limit needs it: the route -pattern matches any single path segment, so a client that invents a -fresh path per request gets a fresh per-entrypoint bucket every time and -would otherwise have no aggregate limit at all — while each of those -requests still costs an entrypoint lookup before it 404s. The aggregate -limit leaves room for one address to drive several entrypoints at their -full rate, and it is not configurable separately. +A second limit sits in front of that one, keyed on the client IP alone and +covering the whole route at ten times `RECEIVER_RATE_LIMIT` requests per minute +(default 1200). The per-entrypoint limit needs it: the route pattern matches any +single path segment, so a client that invents a fresh path per request gets a +fresh per-entrypoint bucket every time and would otherwise have no aggregate +limit at all — while each of those requests still costs an entrypoint lookup +before it 404s. The aggregate limit leaves room for one address to drive several +entrypoints at their full rate, and it is not configurable separately. -What that aggregate limit bounds is the database work an invented path -costs; log volume it caps rather than eliminates. A path that names no -entrypoint is recorded by the handler at `DEBUG`, and the aggregate -limiter logs its own rejections at `DEBUG` and without the path, so -neither appears at all under the default level. The per-entrypoint -limiter is the loud one: it logs every rejection at `WARN` with the -request path, which on this route is attacker-controlled text. A client -hammering a single invented path is served `RECEIVER_RATE_LIMIT` -requests and has the rest of its aggregate budget rejected there, so -the aggregate limit is what bounds the _number_ of those `WARN` lines — -to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 -at the defaults, where before it there was no bound at all. Their -_width_ is bounded by the field budgets below, the same ones the access -log spends. The access log is bounded by neither limit: every request -is recorded once at `INFO`, served or rejected alike. +What that aggregate limit bounds is the database work an invented path costs; +log volume it caps rather than eliminates. A path that names no entrypoint is +recorded by the handler at `DEBUG`, and the aggregate limiter logs its own +rejections at `DEBUG` and without the path, so neither appears at all under the +default level. The per-entrypoint limiter is the loud one: it logs every +rejection at `WARN` with the request path, which on this route is +attacker-controlled text. A client hammering a single invented path is served +`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected +there, so the aggregate limit is what bounds the _number_ of those `WARN` lines +— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the +defaults, where before it there was no bound at all. Their _width_ is bounded by +the field budgets below, the same ones the access log spends. The access log is +bounded by neither limit: every request is recorded once at `INFO`, served or +rejected alike. -What the access log does bound is the _content_ of those lines. A 3xx -or 4xx response logs the chi route pattern — `/webhook/{uuid}`, -`/user/{username}//`, or the literal `(unmatched)` when the request hit -no route at all — in place of the concrete URL. Those are the outcomes -an unauthenticated client can drive for free: 404 and 429 on any -invented receiver path, a login redirect on any invented profile path. -Logging the URL there would let a flood write text of its own choosing, -at a length of its own choosing, into the log. 2xx and 5xx responses -keep the concrete path — a success resolved against a static route or -against the operator's own data (on the receiver, against a stored -entrypoint UUID), and a 5xx is a bug in this service, where the exact -path is the evidence and no client can provoke one at will. +What the access log does bound is the _content_ of those lines. A 3xx or 4xx +response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the +literal `(unmatched)` when the request hit no route at all — in place of the +concrete URL. Those are the outcomes an unauthenticated client can drive for +free: 404 and 429 on any invented receiver path, a login redirect on any +invented profile path. Logging the URL there would let a flood write text of its +own choosing, at a length of its own choosing, into the log. 2xx and 5xx +responses keep the concrete path — a success resolved against a static route or +against the operator's own data (on the receiver, against a stored entrypoint +UUID), and a 5xx is a bug in this service, where the exact path is the evidence +and no client can provoke one at will. The query string is never logged; it is replaced by the fixed marker `?(redacted)`. It is client-chosen on every route, and -`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate -limiter in front of them, so a query on a fixed 200 URL would otherwise -buy the same amplification as an invented path. Nothing debuggable is -lost: `page`, on the authenticated pagination links, is the only query -parameter this service reads. +`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate limiter +in front of them, so a query on a fixed 200 URL would otherwise buy the same +amplification as an invented path. Nothing debuggable is lost: the only query +parameters this service reads are the sign-in page's `next`, the page to return +to, `notice`, which names the line a page shows after an action, and the event +log's `show`, which picks the events it lists. A query string sent to an +entrypoint is not lost either: the event stores it, and the event log shows it. -Client-supplied request content does not leave the host by the other -route either. The Sentry SDK attaches the request to every event it -captures, independently of the access log, and `SendDefaultPII=false` -does not cover all of what it copies: the raw query string and the -first 10 KiB of the request body are both taken unconditionally, the -body precisely because these handlers call `ParseForm`. A `BeforeSend` -hook therefore replaces the query string and the body with -`(redacted)`, drops cookies and the remote-address environment, and +Client-supplied request content does not leave the host by the other route +either. The Sentry SDK attaches the request to every event it captures, +independently of the access log, and `SendDefaultPII=false` does not cover all +of what it copies: the raw query string and the first 10 KiB of the request body +are both taken unconditionally, the body precisely because these handlers call +`ParseForm`. A `BeforeSend` hook therefore replaces the query string and the +body with `(redacted)`, drops cookies and the remote-address environment, and reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, -`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and -`X-Request-Id`. +`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`. The same hook rewrites the request URL. The SDK builds it as -`scheme://host/path` from the concrete path, which on the receiver -route is `/webhook/` in full — and that UUID is a write -capability, not an identifier: anyone holding it can post events this -service accepts and its targets then deliver. A tracker has its own -retention, access control and deletion policy, so the rule the access -log follows above does not carry across that boundary. What is sent is -the chi route pattern instead: `http://host/webhook/{uuid}`. +`scheme://host/path` from the concrete path, which on the receiver route is +`/h/` in full — and that UUID is a write capability, not an identifier: +anyone holding it can post events this service accepts and its targets then +deliver. A tracker has its own retention, access control and deletion policy, so +the rule the access log follows above does not carry across that boundary. What +is sent is the chi route pattern instead: `http://host/h/{uuid}`. -The scheme and the host are kept, and everything else in the URL is -discarded rather than edited, so a future SDK version that starts -appending a query string cannot widen this. The scheme has to survive -for the reason given below. The host is whatever the request's `Host` -header carried — this service validates no hostname, so on a directly -exposed deployment a client sets it — and that same header is on the -allowlist above, so scrubbing the host out of the URL would withhold -nothing that is not sent anyway. +The scheme and the host are kept, and everything else in the URL is discarded +rather than edited, so a future SDK version that starts appending a query string +cannot widen this. The scheme has to survive for the reason given below. The +host is whatever the request's `Host` header carried — this service validates no +hostname, so on a directly exposed deployment a client sets it — and that same +header is on the allowlist above, so scrubbing the host out of the URL would +withhold nothing that is not sent anyway. -The body, the query string and the URL are all handled on every route -rather than filtered by route. For the URL that is also what keeps the -event locatable: an error event is grouped by its exception and stack -trace, not by its URL, so replacing the path with the pattern costs no -grouping and the pattern still names the route in the UI. And an -unconditional rule cannot leak on a route somebody forgets to add to -it, which a route-conditional one can. For the body there is a second -reason: nothing debuggable is lost, because every handler reads its -fields with `PostFormValue`, so the body is exactly where the +The body, the query string and the URL are all handled on every route rather +than filtered by route. For the URL that is also what keeps the event locatable: +an error event is grouped by its exception and stack trace, not by its URL, so +replacing the path with the pattern costs no grouping and the pattern still +names the route in the UI. And an unconditional rule cannot leak on a route +somebody forgets to add to it, which a route-conditional one can. For the body +there is a second reason: nothing debuggable is lost, because every handler +reads its fields with `PostFormValue`, so the body is exactly where the credentials are — the target destination URL, the login password, both -password-change fields — and the one route whose body is genuine -signal is the receiver, whose body is already stored on the event and -served from the UI, so a tracker is not where anyone reads it. +password-change fields — and the one route whose body is genuine signal is the +receiver, whose body is already stored on the event and served from the UI, so a +tracker is not where anyone reads it. -The route is reachable from the hook only on the error dispatch. -`sentryhttp`'s recover path puts the request on the context it hands -to `RecoverWithContext`, and the SDK carries that context through to -`BeforeSend` as `hint.Context`, so -`hint.Context.Value(sentry.RequestContextKey)` yields the live request -and chi's `RoutePattern()` yields the matched pattern off it. The -transaction dispatch has no such request: a finished span captures -with a nil hint, which the client replaces with an empty one, so -`BeforeSendTransaction` sees no context at all. Tracing is off in this -service, so no transaction event is produced today, but the hook is -installed on both dispatches as a floor. +The route is reachable from the hook only on the error dispatch. `sentryhttp`'s +recover path puts the request on the context it hands to `RecoverWithContext`, +and the SDK carries that context through to `BeforeSend` as `hint.Context`, so +`hint.Context.Value(sentry.RequestContextKey)` yields the live request and chi's +`RoutePattern()` yields the matched pattern off it. The transaction dispatch has +no such request: a finished span captures with a nil hint, which the client +replaces with an empty one, so `BeforeSendTransaction` sees no context at all. +Tracing is off in this service, so no transaction event is produced today, but +the hook is installed on both dispatches as a floor. -Where the pattern is out of reach — the transaction dispatch, an event -captured outside the router, or a request that matched no route — the -fallback is never the concrete path. The path becomes the literal -`/(redacted)`, so the URL reads `http://host/(redacted)`; a URL the -rewrite cannot parse into a scheme is withheld whole. A transaction -event additionally carries the SDK's own `METHOD /path` name, built -from the concrete path as well; it is rewritten on the same terms, to -`POST /webhook/{uuid}` where the pattern is known and `POST -/(redacted)` where it is not. +Where the pattern is out of reach — the transaction dispatch, an event captured +outside the router, or a request that matched no route — the fallback is never +the concrete path. The path becomes the literal `/(redacted)`, so the URL reads +`http://host/(redacted)`; a URL the rewrite cannot parse into a scheme is +withheld whole. + +A transaction event also carries a name, which the SDK builds as `METHOD /path` +from the concrete path and gives the transaction source `url`. Sentry groups +transactions by name, so withholding it would put every request in the service +into one group per method, such as `POST /(redacted)`. Inside each Sentry +handler, a second middleware renames the transaction once the request has been +routed, to the method and the route pattern (`POST /h/{uuid}`), and sets its +source to `route`. The hook keeps a name whose source is `route` and rewrites +any other to `METHOD /(redacted)`. A path that matches nothing at the top level +has no pattern, so its transaction keeps the SDK's name and the hook withholds +it. Inside a route group, a path that matches no route in the group, and a +request the group's own middleware turns away before routing (a CSRF refusal, +the redirect to `/pages/login`), are named after the group's pattern, such as +`GET /pages/*` or `POST /hook/{sourceID}/*`, and the hook keeps that name. The +URL on a transaction event still falls back to `http://host/(redacted)`, because +the hook has no request there. The headers are an allowlist for the same reason the rules above are -unconditional: the SDK's own filter removes four names and passes -everything else, which would ship `X-CSRF-Token` and the shared -secrets senders put on the receiver route. What survives still names -the failing route — scheme, host, route pattern, method — and -`X-Request-Id` ties the event to the local access log line that holds -the rest. Nothing dropped is needed for the likeliest use, debugging a -CSRF rejection. Its three inputs are the TLS decision, `Origin` and -`Referer`; the latter two are kept, and the first is the scheme of the -retained URL, because the SDK derives that scheme from -`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That -predicate is the SDK's own and is stricter than `internal/reqtls.IsTLS`, -which this service now uses everywhere it decides transport: the SDK -reports `http` for the `HTTPS` and `https, http` spellings `reqtls` -accepts. Only a reported scheme is affected, no decision is, so it is -left to the SDK rather than reimplemented. That is what the rewrite -above preserves it for, and it is why dropping `X-Forwarded-Proto` -costs nothing. The dropped provider headers (`X-GitHub-Event`, -`X-Gitlab-Event` and the like) are real signal but are recorded -locally on the event, and -`Sentry-Trace`/`Baggage` are already reflected in the event's trace -context. +unconditional: the SDK's own filter removes four names and passes everything +else, which would ship `X-CSRF-Token` and the shared secrets senders put on the +receiver route. What survives still names the failing route — scheme, host, +route pattern, method — and `X-Request-Id` ties the event to the local access +log line that holds the rest. Nothing dropped is needed for the likeliest use, +debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and +`Referer`; the latter two are kept, and the first is the scheme of the retained +URL, because the SDK derives that scheme from +`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate +is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this +service now uses everywhere it decides transport: the SDK reports `http` for the +`HTTPS` and `https, http` spellings `reqtls` accepts. Only a reported scheme is +affected, no decision is, so it is left to the SDK rather than reimplemented. +That is what the rewrite above preserves it for, and it is why dropping +`X-Forwarded-Proto` costs nothing. The dropped provider headers +(`X-GitHub-Event`, `X-Gitlab-Event` and the like) are real signal but are +recorded locally on the event, and `Sentry-Trace`/`Baggage` are already +reflected in the event's trace context. -The remaining client-supplied fields are truncated rather than dropped, -each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`, -128 for `request_id` (chi passes an inbound `X-Request-Id` header -through), and 32 for `method`. A truncated `User-Agent` is still worth -reading; an absent one is not. A cut value ends in `[truncated]`, which -is charged on top of the budget rather than inside it. +The remaining client-supplied fields are truncated rather than dropped, each to +a fixed budget: 512 bytes for `url`, `useragent` and `referer`, 128 for +`request_id` (chi passes an inbound `X-Request-Id` header through), and 32 for +`method`. A truncated `User-Agent` is still worth reading; an absent one is not. +A cut value ends in `[truncated]`, which is charged on top of the budget rather +than inside it. -Each budget is spent in _encoded_ bytes, not in the bytes the client -sent. Every rune is charged what the wider of the two log handlers -emits for it: two bytes for a quotation mark, a backslash or a tab; six -for a non-printable rune below U+10000; ten for one at or above it, -which the text handler spells `\UXXXXXXXX`. Go's header parser accepts -all of them in a header value, so a budget counted raw would buy a -field several times its nominal size — and the line, not the header, is -what an operator has to store. Plain ASCII encodes one byte for one, so -a real browser's `User-Agent` still fits whole; a value built out of -escapes keeps a proportionally shorter prefix, which is the right -trade. +Each budget is spent in _encoded_ bytes, not in the bytes the client sent. Every +rune is charged what the wider of the two log handlers emits for it: two bytes +for a quotation mark, a backslash or a tab; six for a non-printable rune below +U+10000; ten for one at or above it, which the text handler spells `\UXXXXXXXX`. +Go's header parser accepts all of them in a header value, so a budget counted +raw would buy a field several times its nominal size — and the line, not the +header, is what an operator has to store. Plain ASCII encodes one byte for one, +so a real browser's `User-Agent` still fits whole; a value built out of escapes +keeps a proportionally shorter prefix, which is the right trade. -Net: **one `INFO` line per request, of at most 2,560 bytes.** That -ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`, -`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11 -for `method`, plus a 336-byte fixed portion (the field names, the -punctuation, both timestamps at their longest, an IPv6 `remoteIP` with -a zone, the status and the latency) — 2,087 bytes, stated at 2,560 so -the figure has headroom. `internal/middleware/accesslog_test.go` -asserts it against 8 KB of client-chosen text in the path, in the -query, and in each of `User-Agent`, `Referer` and `X-Request-Id`, -including cases built from the characters the handlers escape, and -against the widest access log line the service can be made to write: a -5xx that keeps its concrete path while all three header fields are also -at their budget. Every case runs through both handlers -`internal/logger` can select — the JSON one and the text one it installs -on a tty — since the two do not escape alike and the ceiling is quoted -unqualified. Measured over a real connection, the widest access log line -is 1,972 bytes. +Net: **one `INFO` line per request, of at most 2,560 bytes.** That ceiling is +arithmetic, not an observation: 3 × (512 + 11) for `url`, `useragent` and +`referer`, plus 128 + 11 for `request_id`, plus 32 + 11 for `method`, plus a +405-byte fixed portion (the field names, the punctuation, both timestamps at +their longest, `remoteIP` and `clientIP` each charged as an IPv6 address with a +zone, the status and the latency) — 2,156 bytes, stated at 2,560 so the figure +has headroom. `internal/middleware/accesslog_test.go` asserts it against 8 KB of +client-chosen text in the path, in the query, and in each of `User-Agent`, +`Referer`, `X-Request-Id` and `X-Forwarded-For`, including cases built from the +characters the handlers escape, and against a 5xx that keeps its concrete path +while all three header fields are also at their budget and an `X-Forwarded-For` +sent from a trusted proxy ends in an IPv6 client address at its longest followed +by an 8 KB zone, where `clientIP` must name the address without the zone. Every +case runs through both handlers `internal/logger` can select — the JSON one and +the text one it installs on a tty — since the two do not escape alike and the +ceiling is quoted unqualified. -Multiply that ceiling by the request rate to size log storage. Note -that the rate is not bounded by the limits above on every route: -`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there -the multiplier is whatever the deployment will serve. +Multiply that ceiling by the request rate to size log storage. Note that the +rate is not bounded by the limits above on every route: +`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there the +multiplier is whatever the deployment will serve. -**The same ceiling covers every other line the service writes through -`slog` that carries text an unauthenticated client supplies**, with one -exception stated below it: the recovered-panic record, which carries a -whole goroutine stack alongside its client-supplied fields and so has -its own wider ceiling. The access log is not the only line a client can -put its own text into, and a budget that held for one line and not the -others would be worse than no stated budget at all. Every `slog` call an -unauthenticated request can reach spends the same per-field budget -through `internal/logfield`, and each carries strictly fewer -client-supplied fields than the access log does, so none of them can be -wider than it: +**The same ceiling covers every other line the service writes through `slog` +that carries text an unauthenticated client supplies**, with one exception +stated below it: the recovered-panic record, which carries a whole goroutine +stack alongside its client-supplied fields and so has its own wider ceiling. The +access log is not the only line a client can put its own text into, and a budget +that held for one line and not the others would be worse than no stated budget +at all. Every `slog` call an unauthenticated request can reach spends the same +per-field budget through `internal/logfield`, and each carries strictly fewer +client-supplied fields than the access log does, so none of them can be wider +than it: -| Log line | Level | Client-chosen value | Reachable unauthenticated | -| ------------------------------------------ | ------- | ------------------- | ----------------------------------------- | +| Log line | Level | Client-chosen value | Reachable unauthenticated | +| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ | | `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` | | `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` | | `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | | `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver | -| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form | -| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form | -| `password verification capacity exhausted` | `WARN` | path | yes, on the login form | +| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form | +| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form | +| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form | -`DEBUG` being off by default is not a bound. An operator turning it on -to diagnose a flood must not thereby hand the flood an unbounded write, -so those lines are capped too. +`DEBUG` being off by default is not a bound. An operator turning it on to +diagnose a flood must not thereby hand the flood an unbounded write, so those +lines are capped too. -The last two rows are capped defensively rather than against a -demonstrated width: chi routes `POST /pages/login` on a static pattern, -so `r.URL.Path` there is the 12-byte constant `/pages/login` and each -line lands near 120 bytes. `RecordLoginFailure` is nonetheless an -exported method taking any `*http.Request`, and a future caller on a -route with a URL parameter would widen the line. Since no request -through the mux can, both caps are pinned by tests that call those two -entry points directly with the path such a caller would supply. -Removing either cap fails 14 subtests. +The last two rows are capped defensively rather than against a demonstrated +width: chi routes `POST /pages/login` on a static pattern, so `r.URL.Path` there +is the 12-byte constant `/pages/login` and each line lands near 120 bytes. +`RecordLoginFailure` is nonetheless an exported method taking any +`*http.Request`, and a future caller on a route with a URL parameter would widen +the line. Since no request through the mux can, both caps are pinned by tests +that call those two entry points directly with the path such a caller would +supply. Removing either cap fails 14 subtests. -`internal/middleware/logbound_test.go` and -`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text -at each of these — 1 KB at `invalid password`, whose accounts are -shared with the successful-login line, where a username past 4 KB -overflows the session cookie and answers 500 before that line is -written — through both handlers, and through seven fills: plain text -as the baseline, and then the quotation mark, backslash, tab, newline, -C0 control and astral non-printable, six characters the wider of the -two handlers spends more on than the client spent sending them. Every -case holds each line to the 2,560-byte ceiling. That per-line ceiling -is what the figure above states, and every row establishes it. +`internal/middleware/logbound_test.go` and `internal/handlers/logbound_test.go` +drive 8 KB of client-chosen text at each of these — just under 1 KB at +`invalid password`, whose accounts are shared with the successful-login line and +so must stay within the 1024-byte username limit — through both handlers, and +through seven fills: plain text as the baseline, and then the quotation mark, +backslash, tab, newline, C0 control and astral non-printable, six characters the +wider of the two handlers spends more on than the client spent sending them. +Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is +what the figure above states, and every row establishes it. -Three of the sites go further and bound the whole flood's output — the -total bytes a run of distinct invented values wrote, which is the -shape an operator sizing storage cares about. They are -`request body exceeds limit` +Three of the sites go further and bound the whole flood's output — the total +bytes a run of distinct invented values wrote, which is the shape an operator +sizing storage cares about. They are `request body exceeds limit` (`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`), `entrypoint not found` and `user not found` (the last two through -`assertBoundedFlood`). The other rows carry no aggregate assertion; -the per-line ceiling is what they establish. +`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line +ceiling is what they establish. -`internal/logfield/logfield_test.go` measures the per-rune charge -against what the handlers really emit, over roughly 3,000 code points on -each, so an undercharged rune fails a test rather than quietly -falsifying the ceiling. +`internal/logfield/logfield_test.go` measures the per-rune charge against what +the handlers really emit, over roughly 3,000 code points on each, so an +undercharged rune fails a test rather than quietly falsifying the ceiling. -**It covers GORM's statement logging as well.** GORM's own default -logger printed the fully interpolated SQL — parameters and all — to -standard output on every statement that returned an error, including a -plain record-not-found, at a level no operator setting reached. Two of -this service's lookups miss by design on unauthenticated routes: the -entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind -the login form, whose path segment and submitted username the client -picks outright. Every -`gorm.Open` in the service now installs the adapter in -`internal/gormlog` instead. It writes through the same `slog` logger as -everything else, so its lines take the level the operator set and the -handler `internal/logger` selected, and every value it emits is spent -through the same `internal/logfield` budget. A record-not-found is not -logged as an error: it is the expected outcome on both of those paths, -and each handler already records its own miss at `DEBUG` — bounded, per -the table above — without the SQL. Slow statements are kept, at `WARN`, -above the same 200 ms threshold GORM used and with the statement -bounded, because that report is the one thing GORM's logger gave an -operator that nothing else here does. The adapter orders its cases -exactly as GORM's own `Trace` orders them, so a statement that both -missed and ran slow is still reported as slow, and dropping the miss -costs an operator no report `IgnoreRecordNotFoundError` would have -kept. A GORM line spends at most two of those budgets — the statement -and the driver error — against a smaller fixed portion than the access -log's, and `internal/gormlog/gormlog_test.go` asserts each line against -`MaxAccessLogLineBytes` directly rather than leaving it as arithmetic. +**It covers GORM's statement logging as well.** GORM's own default logger +printed the fully interpolated SQL — parameters and all — to standard output on +every statement that returned an error, including a plain record-not-found, at a +level no operator setting reached. Two of this service's lookups miss by design +on unauthenticated routes: the entrypoint lookup behind `/h/{uuid}` and the user +lookup behind the sign-in form, whose path segment and submitted username the +client picks outright. Every `gorm.Open` in the service now installs the adapter +in `internal/gormlog` instead. It writes through the same `slog` logger as +everything else, so its lines take the level the operator set and the handler +`internal/logger` selected, and every value it emits is spent through the same +`internal/logfield` budget. A record-not-found is not logged as an error: it is +the expected outcome on both of those paths, and each handler already records +its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow +statements are kept, at `WARN`, above the same 200 ms threshold GORM used and +with the statement bounded, because that report is the one thing GORM's logger +gave an operator that nothing else here does. The adapter orders its cases +exactly as GORM's own `Trace` orders them, so a statement that both missed and +ran slow is still reported as slow, and dropping the miss costs an operator no +report `IgnoreRecordNotFoundError` would have kept. A GORM line spends at most +two of those budgets — the statement and the driver error — against a smaller +fixed portion than the access log's, and `internal/gormlog/gormlog_test.go` +asserts each line against `MaxAccessLogLineBytes` directly rather than leaving +it as arithmetic. -The adapter also logs no bound value at all: it implements -`gorm.ParamsFilter` and discards the parameters, so GORM renders the -statement with its placeholders intact instead of substituting the -values into it. That is a separate property from the size bound and it -is what a bound is no substitute for — the session encryption key is 44 -base64 characters and an Argon2id hash under 100, so both fit inside -every budget above and a truncated secret is still a secret. It holds -on all three arms of `Trace`, including the routine one an operator -reaches at `DEBUG`, which is the only level at which a successful -`INSERT` is written at all. One GORM path does not consult the filter — -`(*gorm.DB).Scan`, which records the statement through GORM's own trace -recorder. No production code path calls it; its one caller is -`internal/database/database_test.go:91`, whose `SELECT 1` binds -nothing, and `internal/gormlog/scan_guard_test.go` fails if a non-test -file calls it. `Pluck`, `Row` and `Raw` all run through the normal -callback processor and are filtered. -See `#### What DEBUG=true exposes` under Configuration. +The adapter also logs no bound value at all: it implements `gorm.ParamsFilter` +and discards the parameters, so GORM renders the statement with its placeholders +intact instead of substituting the values into it. That is a separate property +from the size bound and it is what a bound is no substitute for — the session +encryption key is 44 base64 characters and an Argon2id hash under 100, so both +fit inside every budget above and a truncated secret is still a secret. It holds +on all three arms of `Trace`, including the routine one an operator reaches at +`DEBUG`, which is the only level at which a successful `INSERT` is written at +all. One GORM path does not consult the filter — `(*gorm.DB).Scan`, which +records the statement through GORM's own trace recorder. No production code path +calls it; only tests do, and what a test binds is fixture data. +`internal/gormlog/scan_guard_test.go` fails if a non-test file calls it. +`Pluck`, `Row` and `Raw` all run through the normal callback processor and are +filtered. See `#### What DEBUG=true exposes` under Configuration. -What that ceiling does **not** cover, stated here so the figure is not -read as more than it is: +What that ceiling does **not** cover, stated here so the figure is not read as +more than it is: -- **Lines carrying an authenticated operator's own input**, which are - not truncated at all. `webhook created` logs the submitted `name` - verbatim and `target URL blocked by SSRF protection` logs the target - host (both `internal/handlers/source_management.go`), as do the - `target_name` lines in `internal/delivery/engine.go` and - `internal/delivery/target_http.go`. The only bound on any of them is - the 1 MB form body cap, so a 100 KB `name` writes a single line of - roughly 600 KB — measured. This is deliberate: every one of these - requires an authenticated operator on a service with no - self-registration, and truncating the operator's own configuration - echoed back would cost debuggability against no adversary. It does - mean the 2,560-byte figure sizes unauthenticated traffic, not the - operator's own administrative requests. -- The **`log` delivery target**, which writes the whole inbound event — - headers and body — to the log. This one is deliberate: capping it - would defeat the target, since emitting the payload is the delivery. - It costs nothing unless an authenticated operator creates a target of - that type on a specific webhook, and each line it writes is bounded - per event by the 1 MB receiver body cap. Adding one is a decision to - spend log volume on that webhook's payloads. -- **Two writers that do not go through `internal/logger` at all**, both - on standard error. `fx` prints the dependency graph and the lifecycle - hooks through its default console logger at startup and shutdown — - nothing calls `fx.WithLogger`, and `fx.New` builds that logger over - `os.Stderr`. The Go runtime writes a panic or a fatal error itself; a - panic in a background worker rather than in a request handler is the - case that reaches it, since nothing recovers those. Neither carries a - client-chosen value at a client-chosen length: the five `panic` calls - in this service are invariant guards over constants and over - `crypto/rand`. +- **Lines carrying an authenticated operator's own input**, which are not + truncated at all. `webhook created` logs the submitted `name` verbatim + (`internal/handlers/webhook_create.go`) and + `target URL blocked by SSRF protection` logs the target host + (`internal/handlers/target_create.go`), as do the `target_name` lines in + `internal/delivery/engine.go` and `internal/delivery/target_http.go`. The only + bound on any of them is the 1 MB form body cap, so a 100 KB `name` writes a + single line of roughly 600 KB — measured. This is deliberate: every one of + these requires an authenticated operator on a service with no + self-registration, and truncating the operator's own configuration echoed back + would cost debuggability against no adversary. It does mean the 2,560-byte + figure sizes unauthenticated traffic, not the operator's own administrative + requests. +- The **`log` delivery target**, which writes the whole inbound event — headers + and body — to the log. This one is deliberate: capping it would defeat the + target, since emitting the payload is the delivery. It costs nothing unless an + authenticated operator creates a target of that type on a specific webhook, + and each line it writes is bounded per event by the 1 MB receiver body cap. + Adding one is a decision to spend log volume on that webhook's payloads. +- **The Go runtime**, which does not go through `internal/logger`. The runtime + writes an unrecovered panic or a fatal error itself, as plain text on standard + error, and that output cannot be redirected. A panic in a background worker + rather than in a request handler is the case that reaches it, since nothing + recovers those. It carries no client-chosen value at a client-chosen length: + the service's own `panic` calls are invariant guards over constants and over + `crypto/rand`, apart from the one that hands `http.ErrAbortHandler` back to + `net/http`, described below. +- **A failure before fx's logger is built**, such as an invalid configuration + value. fx's logger takes the configuration, so when that fails fx's own + console logger still prints the failure as plain text on standard error. Its + values come from the operator's environment, not from a client. - **`net/http`'s own faults**, which are _not_ a separate writer. `internal/server/http.go` builds its server with a nil `ErrorLog`, so `net/http` falls back to the `log` package's default logger — and - `internal/logger` calls `slog.SetDefault`, which redirects that logger - into whichever handler it installed. Those lines therefore arrive on - standard output, shaped like every other line, at `INFO`. They are not - truncated. A handler panic is no longer one of them: the recover - middleware below answers it and writes it as the bounded record - described there instead, and `internal/server/recoverer_test.go` - requires that `http: panic serving` appear in neither of the process's - two streams when a panic is driven through the production router. The - one panic still handed back to `net/http` is `http.ErrAbortHandler`, - which it special-cases and does not log at all. What is left on this - path is `net/http`'s own diagnostics, whose values are the runtime's, - not a client's. + `internal/logger` calls `slog.SetDefault`, which redirects that logger into + whichever handler it installed. Those lines therefore arrive on standard + output, shaped like every other line, at `INFO`. They are not truncated. A + handler panic is no longer one of them: the recover middleware below answers + it and writes it as the bounded record described there instead, and + `internal/server/recoverer_test.go` requires that `http: panic serving` appear + in neither of the process's two streams when a panic is driven through the + production router. The one panic still handed back to `net/http` is + `http.ErrAbortHandler`, which it special-cases and does not log at all. What + is left on this path is `net/http`'s own diagnostics, whose values are the + runtime's, not a client's. -Wider than that 2,560-byte ceiling, and stated separately rather than -carved out of it: the record a recovered panic produces. The recover -middleware in `internal/middleware` answers `500` and writes one `ERROR` -record through `internal/logger` carrying the panic value, the stack and -the request id — the same `request_id` the access log line for that -request carries, which is how the two are joined. It replaced chi's -`middleware.Recoverer`, which on a current Go release crashed inside its -own stack pretty-printer: the connection was dropped rather than -answered, and what reached the operator described that crash rather than -the fault behind it. +Wider than that 2,560-byte ceiling, and stated separately rather than carved out +of it: the record a recovered panic produces. The recover middleware in +`internal/middleware` answers `500` and writes one `ERROR` record through +`internal/logger` carrying the panic value, the stack and the request id — the +same `request_id` the access log line for that request carries, which is how the +two are joined. It replaced chi's `middleware.Recoverer`, which on a current Go +release crashed inside its own stack pretty-printer: the connection was dropped +rather than answered, and what reached the operator described that crash rather +than the fault behind it. -That record is bounded the same way, in the same encoded bytes and -through the same `internal/logfield` budget: 512 for the panic value, -because a handler is free to build one out of the request, 128 for the -request id, which a client supplies outright through `X-Request-Id`, -and 8,192 for the stack, cut at its far end so that the panic site -survives a cut and `net/http`'s accept frames are what is lost. Net: -**at most 10,240 bytes, once per recovered panic** — 9,121 by the -arithmetic (523 + 8,203 + 139 + a 256-byte fixed portion), stated at -10,240 for headroom. +That record is bounded the same way, in the same encoded bytes and through the +same `internal/logfield` budget: 512 for the panic value, because a handler is +free to build one out of the request, 128 for the request id, which a client +supplies outright through `X-Request-Id`, and 8,192 for the stack, cut at its +far end so that the panic site survives a cut and `net/http`'s accept frames are +what is lost. Net: **at most 10,240 bytes, once per recovered panic** — 9,121 by +the arithmetic (523 + 8,203 + 139 + a 256-byte fixed portion), stated at 10,240 +for headroom. -Those two numbers are the claim; the measurements below only -illustrate it. `internal/middleware/recoverer_test.go` drives all -three growable fields past their budgets on one record, over both -handlers, and measured 9,009 bytes on the JSON handler and -8,982–8,983 on the text one in one checkout. Neither is an invariant: -the stack's own content decides where its cut lands, so the figures -move by a byte or so between runs. The real case is far below both — -through the shipped middleware chain the whole record measures -roughly 3,960 bytes over a roughly 3,690-byte stack, taken by -`internal/server/recoverer_test.go` from the process's own file -descriptors while driving a panic through the production router over -a real server in a subprocess. That pair moves further still, since -`debug.Stack()` embeds absolute source paths and so depends on where -the tree is checked out: four checkouts have reported 3,959, 3,961, -3,984 and 4,026. What the tests assert is the ceiling, that every -client-supplied field was cut, and that the shipped chain's stack -arrived uncut — never the numbers. +Those two numbers are the claim; the measurements below only illustrate it. +`internal/middleware/recoverer_test.go` drives all three growable fields past +their budgets on one record, over both handlers, and measured 9,009 bytes on the +JSON handler and 8,982–8,983 on the text one in one checkout. Neither is an +invariant: the stack's own content decides where its cut lands, so the figures +move by a byte or so between runs. The real case is far below both — through the +shipped middleware chain the whole record measures roughly 3,960 bytes over a +roughly 3,690-byte stack, taken by `internal/server/recoverer_test.go` from the +process's own file descriptors while driving a panic through the production +router over a real server in a subprocess. That pair moves further still, since +`debug.Stack()` embeds absolute source paths and so depends on where the tree is +checked out: four checkouts have reported 3,959, 3,961, 3,984 and 4,026. What +the tests assert is the ceiling, that every client-supplied field was cut, and +that the shipped chain's stack arrived uncut — never the numbers. -Every limiter here — receiver, login, and password change — identifies -the client the same way, through one shared key function: the -connection's own address, unless the peer is listed in -`TRUSTED_PROXIES`, in which case the forwarded client address is used -instead. That address becomes a bucket by family: IPv4 keys on the full -address, IPv6 on its `/64` prefix. A routed `/64` is the normal -residential and mobile IPv6 allocation, so keying IPv6 per address would -let one subscriber rotate source addresses and mint a fresh bucket per -request, evading these limits at the network layer without spoofing -anything; the cost is that distinct clients inside one `/64` share a -bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address -they carry. See [Trusted proxies](#trusted-proxies). Deployed without that -variable set, a client behind a reverse proxy shares one bucket with -every other client behind the same proxy. Set `TRUSTED_PROXIES` to the -proxy's address to get per-client limits back. What the shared bucket -costs is not the same for every limiter, and the two cases pull in -opposite directions: +Every limiter here — receiver, login, password change, delivery replay and event +resubmit — identifies the client the same way, through one shared key function: +the connection's own address, unless the peer is inside `TRUSTED_PROXIES`, in +which case the forwarded client address is used instead. That address becomes a +bucket by family: IPv4 keys on the full address, IPv6 on its `/64` prefix. A +routed `/64` is the normal residential and mobile IPv6 allocation, so keying +IPv6 per address would let one subscriber rotate source addresses and mint a +fresh bucket per request, evading these limits at the network layer without +spoofing anything; the cost is that distinct clients inside one `/64` share a +bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address they +carry. See [Trusted proxies](#trusted-proxies). When that variable does not +cover the reverse proxy, a client behind it shares one bucket with every other +client behind the same proxy. Set `TRUSTED_PROXIES` to the proxy's address to +get per-client limits back. What the shared bucket costs is not the same for +every limiter, and the two cases pull in opposite directions: -- For the **receiver** limits it costs throughput, which is the safe - direction to be wrong in: sharing can only make a limit bind sooner, - never let a sender past it. It matters more for the aggregate limit - than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind - the reverse proxy a production deployment is required to run behind, - every request keys on the proxy, so the aggregate limit becomes a - service-wide ceiling of 1200 requests per minute across all senders - and all entrypoints, where the per-entrypoint limit's capacity still - grows with the number of entrypoints. Any deployment with more than a - handful of busy entrypoints must set `TRUSTED_PROXIES`. +- For the **receiver** limits it costs throughput, which is the safe direction + to be wrong in: sharing can only make a limit bind sooner, never let a sender + past it. It matters more for the aggregate limit than for the per-entrypoint + one: with every request keyed on the proxy, the aggregate limit becomes a + service-wide ceiling of 1200 requests per minute across all senders and all + entrypoints, where the per-entrypoint limit's capacity still grows with the + number of entrypoints. - For the **login and password-change** limits it costs precision, not - availability. Login failures from every client land in one counter, - so a stranger's wrong passwords make the operator's own wrong - passwords answer `429` sooner; the operator's _correct_ password is - never affected, because it is never counted. Production deployments - should still set `TRUSTED_PROXIES`; webhooker warns at startup - whenever it is empty, in any environment. + availability. Login failures from every client land in one counter, so a + stranger's wrong passwords make the operator's own wrong passwords answer + `429` sooner; the operator's _correct_ password is never affected, because it + is never counted. #### The login endpoint -The login `POST` is the one endpoint with no pre-emptive limiter in -front of it, and that is deliberate. A limiter that spends budget on -arrival is a lockout in this deployment shape: sharing one bucket, a -stranger sending five POSTs a minute — about 0.08 requests per second, -from anywhere — keeps it permanently full, and the operator has no -second administrative path. So the handler inverts the order: +The login `POST` is the one endpoint with no pre-emptive limiter in front of it, +and that is deliberate. A limiter that spends budget on arrival is a lockout +wherever clients share one bucket, as they do behind a reverse proxy that +`TRUSTED_PROXIES` does not cover: a stranger sending five POSTs a minute — about +0.08 requests per second, from anywhere — keeps it permanently full, and the +operator has no second administrative path. So the handler inverts the order: -1. **Credentials are verified first, and only a failed attempt spends - budget.** A correct password is never rate-limited, whatever the - counters hold. This is what guarantees the admin UI stays - reachable. -2. **Failures are counted per (client bucket, submitted username)**, - five per minute, after which further _failures_ from that pair are - answered `429` with a `Retry-After`. That `429` is a label on the - response, not a gate in front of the work: the credential check has - already run by the time the counter is consulted, so a throttled - client's guess is still evaluated. See the guessing rate below. A - successful login clears the counter, so mistyping a few times and - then getting it right leaves you unthrottled. Because the submitted - username is attacker-controlled, at most 1024 username counters and - 1024 fallback address counters are tracked; past the first cap - failures fall back to the address counter, and past both they are - answered as throttled without being recorded. Total tracked state - is under half a megabyte and does not grow with the number of - usernames an attacker invents. -3. **Concurrent password verifications are capped at two, and the - queue for them at 16.** Verifying before counting means every login - request costs an Argon2id hash, and Argon2id here is 64 MB per - hash — two slots is a 128 MB ceiling on password hashing. Every - endpoint that hashes a password takes a slot, including the - password-change endpoint, which holds one across both the - verification and the new hash. A request that waits five seconds - without getting a slot is answered `503 Service Unavailable` and no - hash is computed for it. The wait alone does not bound memory, only - how long one request holds some, so the number of waiters is capped - as well. Size the queue from what a parked waiter actually retains, - not from the 1 MB body cap: that caps the raw body read, while the - body-cap, CSRF and form-parsing middleware all run before the - guard, so a waiter holds its parsed form plus its request header - block for the whole wait. Measured on the pinned Go 1.26.1 - toolchain, as the heap delta with 64 waiters parked in the handler, - an ordinary two-field login form retains ~0 MB, a 1 MB urlencoded - body at Go's 10,000-parameter parse cap retains 2.82 MB (3.09 MB - with `%41` escapes), and the ~0.9 MB of headers the 1 MB header cap - allows takes it to **4.18 MB** — the retained parse and the headers - dominate, not the raw body. So the cap is 16 waiters: 16 x 4.18 MB - is about 67 MB of committed queue memory, and two slots drain a - full 16-deep queue in roughly 0.6 s, far inside the five-second - deadline. A request arriving past the cap is shed with `503` - immediately instead of joining the queue. **Peak commitment for the - endpoint is therefore about 203 MB**: 128 MB of Argon2id, plus the - 18 requests holding a parsed form — 16 queued and the 2 being - hashed — at about 75 MB. That 203 MB is _live_ commitment, not - resident size: the Go collector lets the heap reach roughly twice - the live set before collecting, with transient parse garbage on top - of it. The independent review of this endpoint fired 18 adversarial - requests at an idle guard and measured a peak `HeapAlloc` of - 392 MB. **Provision on the order of 400 MB**, not for the 203 MB - itemised here and not for the hashing budget alone. +1. **Credentials are verified first, and only a failed attempt spends budget.** + A correct password is never rate-limited, whatever the counters hold. This is + what guarantees the admin UI stays reachable. +2. **Failures are counted per (client bucket, submitted username)**, five per + minute, after which further _failures_ from that pair are answered `429` with + a `Retry-After`. That `429` is a label on the response, not a gate in front + of the work: the credential check has already run by the time the counter is + consulted, so a throttled client's guess is still evaluated. See the guessing + rate below. A successful login clears the counter, so mistyping a few times + and then getting it right leaves you unthrottled. Because the submitted + username is attacker-controlled, at most 1024 username counters and 1024 + fallback address counters are tracked; past the first cap failures fall back + to the address counter, and past both they are answered as throttled without + being recorded. Total tracked state is under half a megabyte and does not + grow with the number of usernames an attacker invents. +3. **Concurrent password verifications are capped at two, and the queue for them + at 16.** Verifying before counting means every login request costs an + Argon2id hash, and Argon2id here is 64 MB per hash — two slots is a 128 MB + ceiling on password hashing. Every endpoint that hashes a password takes a + slot, including the password-change endpoint, which holds one across both the + verification and the new hash. A request that waits five seconds without + getting a slot is answered `503 Service Unavailable` and no hash is computed + for it. The wait alone does not bound memory, only how long one request holds + some, so the number of waiters is capped as well. Size the queue from what a + parked waiter actually retains, not from the 1 MB body cap: that caps the raw + body read, while the body-cap, CSRF and form-parsing middleware all run + before the guard, so a waiter holds its parsed form plus its request header + block for the whole wait. Measured on the pinned Go 1.26.1 toolchain, as the + heap delta with 64 waiters parked in the handler, an ordinary two-field login + form retains ~0 MB, a 1 MB urlencoded body at Go's 10,000-parameter parse cap + retains 2.82 MB (3.09 MB with `%41` escapes), and the ~0.9 MB of headers the + 1 MB header cap allows takes it to **4.18 MB** — the retained parse and the + headers dominate, not the raw body. So the cap is 16 waiters: 16 x 4.18 MB is + about 67 MB of committed queue memory, and two slots drain a full 16-deep + queue in roughly 0.6 s, far inside the five-second deadline. A request + arriving past the cap is shed with `503` immediately instead of joining the + queue. **Peak commitment for the endpoint is therefore about 203 MB**: 128 MB + of Argon2id, plus the 18 requests holding a parsed form — 16 queued and the 2 + being hashed — at about 75 MB. That 203 MB is _live_ commitment, not resident + size: the Go collector lets the heap reach roughly twice the live set before + collecting, with transient parse garbage on top of it. The independent review + of this endpoint fired 18 adversarial requests at an idle guard and measured + a peak `HeapAlloc` of 392 MB. **Provision on the order of 400 MB**, not for + the 203 MB itemised here and not for the hashing budget alone. -An unknown username is verified against a dummy hash rather than -rejected early, so a nonexistent account costs the same time as a real -one and the response cannot be used to enumerate usernames. +An unknown username is verified against a dummy hash rather than rejected early, +so a nonexistent account costs the same time as a real one and the response +cannot be used to enumerate usernames. -**This raises online guessing throughput by about 300x, and that is -the trade.** Because the credential check always precedes the counter, -what bounds online brute force is the semaphore, not the failure -counter. Two slots at the cost of one Argon2id verification is on the -order of **27 guesses per second, about 2.3 million per day**, against -5 per minute under the pre-emptive limiter this replaced. Treat that -figure as a lower bound rather than a ceiling: it was measured with -Go's race detector enabled, so real hardware verifies faster and -guesses faster. Choose the admin password to survive millions of -online guesses per day — a long random passphrase, not a memorable -one. Rate-limiting `POST /pages/login` at the reverse proxy, where the -real client address is visible, is the way to put a cheaper bound back -on top. +**This raises online guessing throughput by about 300x, and that is the trade.** +Because the credential check always precedes the counter, what bounds online +brute force is the semaphore, not the failure counter. Two slots at the cost of +one Argon2id verification is on the order of **27 guesses per second, about 2.3 +million per day**, against 5 per minute under the pre-emptive limiter this +replaced. Treat that figure as a lower bound rather than a ceiling: it was +measured with Go's race detector enabled, so real hardware verifies faster and +guesses faster. Choose the admin password to survive millions of online guesses +per day — a long random passphrase, not a memorable one. Rate-limiting +`POST /pages/login` at the reverse proxy, where the real client address is +visible, is the way to put a cheaper bound back on top. -The residual exposure is a bounded, self-clearing loss of login -**availability** — not merely of latency. A flood can keep both -verification slots busy, and a request that neither gets a slot within -five seconds nor finds room in the queue is answered `503`. Above -roughly 27 requests per second the operator is not served slowly, it -is shed: its chance per attempt is about the ratio of service rate to -flood rate, so at 400 requests per second it is roughly one attempt in -fourteen. A sufficiently determined flood still denies login for as -long as it runs. +The residual exposure is a bounded, self-clearing loss of login **availability** +— not merely of latency. A flood can keep both verification slots busy, and a +request that neither gets a slot within five seconds nor finds room in the queue +is answered `503`. Above roughly 27 requests per second the operator is not +served slowly, it is shed: its chance per attempt is about the ratio of service +rate to flood rate, so at 400 requests per second it is roughly one attempt in +fourteen. A sufficiently determined flood still denies login for as long as it +runs. -What changed is the price and the aftermath. Denying login used to -cost an attacker 0.08 requests per second from anywhere; it now costs -30 or more sustained, about 400 times as much. Nothing accumulates -while the flood runs, nothing needs resetting when it stops, and the -operator's correct password succeeds on the first attempt afterwards. -Restarting the service is **not** a remedy: a restart clears the -failure counters, which are not what is saturated, and the flood -re-fills both verification slots on its first two requests. The -remedies are to block the source at the reverse proxy, or to -rate-limit `POST /pages/login` there — the one place a limit can be -applied without reintroducing the lockout, because the proxy sees the -real client address. Setting `TRUSTED_PROXIES` does not stop the -saturation, but it makes the source visible in the failure logs. +What changed is the price and the aftermath. Denying login used to cost an +attacker 0.08 requests per second from anywhere; it now costs 30 or more +sustained, about 400 times as much. Nothing accumulates while the flood runs, +nothing needs resetting when it stops, and the operator's correct password +succeeds on the first attempt afterwards. Restarting the service is **not** a +remedy: a restart clears the failure counters, which are not what is saturated, +and the flood re-fills both verification slots on its first two requests. The +remedies are to block the source at the reverse proxy, or to rate-limit +`POST /pages/login` there — the one place a limit can be applied without +reintroducing the lockout, because the proxy sees the real client address. +`TRUSTED_PROXIES` does not stop the saturation. The flood's source is in the +`clientIP` field of webhooker's access log while `TRUSTED_PROXIES` covers the +proxy, and in the proxy's own access log either way (see +[Trusted proxies](#trusted-proxies)). -Finer-grained per-webhook rate limits (configured in the web UI and -enforced in the webhook handler) can layer on top of this env-level -abuse limit later; they are tracked as future work. +Finer-grained per-webhook rate limits (configured in the web UI and enforced in +the webhook handler) can layer on top of this env-level abuse limit later; they +are tracked as future work. ### API Endpoints #### Public Endpoints -| Method | Path | Description | -| ------ | --------------------------- | ----------- | -| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) | -| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) | -| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | -| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | +| Method | Path | Description | +| ------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) | +| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) | +| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | +| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | #### Authentication Endpoints -| Method | Path | Description | -| ------ | --------------- | ----------- | -| `GET` | `/pages/login` | Login page (not rate limited) | -| `POST` | `/pages/login` | Login form submission. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | -| `POST` | `/pages/logout` | Logout (destroys session) | +| Method | Path | Description | +| ------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` | +| `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | +| `POST` | `/pages/logout` | Sign out (destroys session) | #### Authenticated Endpoints -| Method | Path | Description | -| ------ | ------------------------ | ----------- | -| `GET` | `/user/{username}` | User profile page | -| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | -| `GET` | `/sources` | List user's webhooks | -| `GET` | `/sources/new` | Create webhook form | -| `POST` | `/sources/new` | Create webhook submission | -| `GET` | `/source/{id}` | Webhook detail view | -| `GET` | `/source/{id}/edit` | Edit webhook form | -| `POST` | `/source/{id}/edit` | Edit webhook submission | -| `POST` | `/source/{id}/delete` | Delete webhook | -| `GET` | `/source/{id}/logs` | Webhook event logs | -| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated | -| `POST` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | -| `POST` | `/source/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | -| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook | -| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | -| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | -| `POST` | `/source/{id}/targets` | Add target to webhook | -| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | -| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission | -| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target | -| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target | +A signed-out `GET` of any of these is redirected to `/pages/login` with its path +and query as `next` when they fit in 2048 bytes, so signing in returns to the +page that was asked for. + +| Method | Path | Description | +| ------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/user/{username}` | User profile page | +| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | +| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set | +| `GET` | `/hooks` | List user's webhooks | +| `GET` | `/hooks/new` | Create webhook form | +| `POST` | `/hooks/new` | Create webhook submission | +| `GET` | `/hook/{id}` | Webhook detail view | +| `GET` | `/hook/{id}/edit` | Edit webhook form | +| `POST` | `/hook/{id}/edit` | Edit webhook submission | +| `POST` | `/hook/{id}/delete` | Delete webhook | +| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | +| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its query string, its request headers, its whole body and every delivery of it | +| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | +| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | +| `POST` | `/hook/{id}/targets` | Add target to webhook | +| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | +| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission | +| `GET` | `/hook/{id}/targets/{targetID}/download` | Download a `database` target's archive as one gzipped JSON file. See [Database Architecture](#database-architecture) | +| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target | +| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target | #### Infrastructure Endpoints -| Method | Path | Description | -| ------ | ---------- | ----------- | +| Method | Path | Description | +| ------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/metrics` | Prometheus metrics, behind basic auth. The route is registered only when `METRICS_USERNAME` and `METRICS_PASSWORD` are both set; with neither set it does not exist and returns 404, and with only one set the process refuses to start | #### API (Planned) -| Method | Path | Description | -| -------- | ------------------------------ | ----------- | -| `GET` | `/api/v1/webhooks` | List webhooks | -| `POST` | `/api/v1/webhooks` | Create webhook | -| `GET` | `/api/v1/webhooks/{id}` | Get webhook details | -| `PUT` | `/api/v1/webhooks/{id}` | Update webhook | -| `DELETE` | `/api/v1/webhooks/{id}` | Delete webhook | -| `GET` | `/api/v1/webhooks/{id}/events` | List events for webhook | -| `POST` | `/api/v1/events/{id}/redeliver`| Redeliver an event | +| Method | Path | Description | +| -------- | ------------------------------- | ----------------------- | +| `GET` | `/api/v1/webhooks` | List webhooks | +| `POST` | `/api/v1/webhooks` | Create webhook | +| `GET` | `/api/v1/webhooks/{id}` | Get webhook details | +| `PUT` | `/api/v1/webhooks/{id}` | Update webhook | +| `DELETE` | `/api/v1/webhooks/{id}` | Delete webhook | +| `GET` | `/api/v1/webhooks/{id}/events` | List events for webhook | +| `POST` | `/api/v1/events/{id}/redeliver` | Redeliver an event | -None of these exist yet. `/api/v1` is mounted with no routes, so every -path under it returns 404 today. API authentication will use API keys -passed via `Authorization: Bearer ` header; no Bearer middleware -is implemented either. +None of these exist yet. `/api/v1` is mounted with no routes, so every path +under it returns 404 today. API authentication will use API keys passed via +`Authorization: Bearer ` header; no Bearer middleware is implemented +either. ### Package Layout -All application code lives under `internal/` to prevent external -imports. The entry point is `cmd/webhooker/main.go`. +All application code lives under `internal/` to prevent external imports. The +entry point is `cmd/webhooker/main.go`. ``` webhooker/ +├── 3p/ +│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets ├── cmd/webhooker/ │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx ├── internal/ │ ├── banner/ │ │ └── banner.go # Ruled block for the one credential shown in the clear -│ ├── ciscript/ -│ │ └── doc.go # Tests for the CI shell scripts in script/; no runtime code │ ├── resetpw/ │ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only │ ├── config/ -│ │ └── config.go # Configuration loading from environment variables +│ │ ├── config.go # Configuration loading from environment variables +│ │ └── configtest/ # Test support: ClearEnv, an empty environment for one test │ ├── database/ │ │ ├── base_model.go # BaseModel with UUID primary keys │ │ ├── database.go # GORM connection, migrations, admin seed -│ │ ├── models.go # AutoMigrate for config-tier models │ │ ├── model_setting.go # Setting entity (key-value app config) │ │ ├── model_user.go # User entity │ │ ├── model_webhook.go # Webhook entity @@ -2778,13 +2990,19 @@ webhooker/ │ │ ├── model_event.go # Event entity (per-webhook DB) │ │ ├── model_delivery.go # Delivery entity (per-webhook DB) │ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB) +│ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB) │ │ ├── model_apikey.go # APIKey entity │ │ ├── password.go # Argon2id hashing and verification │ │ ├── retention.go # Retention reaper (per-webhook event expiry) -│ │ ├── testing.go # NewTestDatabase: wrapper for tests, no fx lifecycle -│ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager +│ │ ├── webhook_db_manager.go # Per-webhook DB lifecycle manager +│ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages │ ├── datadir/ │ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance) +│ ├── db/migrations/ +│ │ ├── migrations.go # Applies a database file's SQL files when it is opened +│ │ ├── main/ # 000_migration.sql and 001_schema.sql for webhooker.db +│ │ ├── events/ # The same for each events-{uuid}.db +│ │ └── archive/ # The same for each archive file │ ├── globals/ │ │ └── globals.go # Build-time variables (appname, version, arch) │ ├── gormlog/ @@ -2799,6 +3017,8 @@ webhooker/ │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target │ │ ├── target_database.go # Database archive target │ │ ├── target_database_archive.go # Archive file lifecycle and pruning +│ │ ├── target_database_rotation.go # Archive rotation and file names +│ │ ├── target_database_export.go # Archive download as gzipped JSON │ │ ├── target_log.go # Log target (stdout) │ │ ├── target_config_view.go # Masked target config for templates │ │ ├── archive_sweeper.go # Periodic pruning of idle archives @@ -2815,14 +3035,25 @@ webhooker/ │ │ ├── healthcheck.go # Health check handler │ │ ├── index.go # Index page handler │ │ ├── profile.go # User profile handler -│ │ ├── source_management.go # Webhook CRUD handlers +│ │ ├── settings.go # Read-only Settings page handler +│ │ ├── webhook_list.go # Webhook list page +│ │ ├── webhook_create.go # Webhook create +│ │ ├── webhook_detail.go # Webhook detail page +│ │ ├── webhook_edit.go # Webhook edit, archive renaming +│ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal +│ │ ├── event_log.go # Event log page: loaders, filters, delivery views +│ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle +│ │ ├── target_create.go # Target create, per-type config builders +│ │ ├── target_delete.go # Target delete +│ │ ├── target_toggle.go # Target toggle +│ │ ├── shared.go # Helpers shared by several handlers │ │ └── webhook.go # Webhook receiver handler │ ├── healthcheck/ │ │ └── healthcheck.go # Health check service (uptime, version) │ ├── lifecycle/ │ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context │ ├── logger/ -│ │ └── logger.go # slog setup with TTY detection +│ │ └── logger.go # slog setup with TTY detection; fx's event logger │ ├── metrics/ │ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type │ ├── middleware/ @@ -2830,7 +3061,7 @@ webhooker/ │ │ ├── csrf.go # CSRF protection middleware (gorilla/csrf) │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore -│ │ └── testing.go # NewForTest: Middleware without the fx lifecycle +│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages │ ├── reqtls/ │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ ├── server/ @@ -2838,25 +3069,28 @@ webhooker/ │ │ ├── http.go # HTTP server setup with timeouts │ │ └── routes.go # All route definitions │ ├── session/ -│ │ ├── session.go # Cookie-based session management -│ │ └── testing.go # NewForTest: Session without the fx lifecycle +│ │ └── session.go # Cookie-based session management │ └── versionscript/ │ └── doc.go # Tests for script/version and the build files that use it ├── static/ │ ├── static.go # //go:embed directive │ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/tailwind.css # Generated stylesheet the pages load -│ ├── css/style.css # Older hand-written stylesheet, no longer loaded -│ ├── js/app.js # Progressive-enhancement copy-to-clipboard -│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed -│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against +│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline +│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components +│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed ├── templates/ # Go HTML templates (base, login, sources, etc.) ├── script/ # Scripts to Rule Them All entrypoints -├── Dockerfile # Three stages: lint, test+build, Alpine runtime -├── Dockerfile.lint # Lint-only image built by script/lint -├── Makefile # 10 of 17 targets shim script/; 7 are inline +├── Dockerfile # Stages: stylesheet, JavaScript lint, Markdown, lint, test, build, Alpine runtime +├── Dockerfile.browser # Browser test image built by script/test-browser +├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── go.mod / go.sum -└── .golangci.yml # Linter configuration +├── package.json / yarn.lock # ESLint, prettier and yarn, pinned, for the JavaScript lint and Markdown stages +├── .yarnrc.yml # yarn settings: install into node_modules/ +├── eslint.config.mjs # ESLint configuration for static/js/ +├── .prettierrc # prettier settings for the Markdown +├── .prettierignore # Files prettier skips +└── .golangci.yml # golangci-lint configuration ``` ### Dependency Injection @@ -2866,382 +3100,370 @@ Components are wired via Uber fx in this order: 1. `globals.New` — Build-time variables (appname, version, arch) 2. `logger.New` — Structured logging (slog with TTY detection) 3. `config.New` — Configuration loading (environment variables) -4. `database.New` — Main SQLite connection, config migrations, admin - user seed -5. `database.NewWebhookDBManager` — Per-webhook event database - lifecycle manager +4. `database.New` — Main SQLite connection, config migrations, admin user seed +5. `database.NewWebhookDBManager` — Per-webhook event database lifecycle manager 6. `database.NewRetentionReaper` — Per-webhook event retention sweep 7. `healthcheck.New` — Health check service 8. `session.New` — Cookie-based session manager (key from database) 9. `handlers.New` — HTTP handlers -10. `middleware.New` — HTTP middleware -11. `delivery.New` — Event-driven delivery engine -12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives -13. `delivery.Engine` → `delivery.Notifier` — interface bridge -14. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so - deleting a webhook releases its archive writer -15. `server.New` — HTTP server and router +10. `metrics.NewRegistry` — The registry `/metrics` serves +11. `metrics.New` — The delivery collectors, registered on that registry +12. `middleware.New` — HTTP middleware +13. `delivery.New` — Event-driven delivery engine +14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives +15. `delivery.Engine` → `delivery.Notifier` — interface bridge +16. `delivery.Engine` → `delivery.Archives` — interface bridge so deleting or + renaming a webhook or target reaches its archive files +17. `server.New` — HTTP server and router -The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine, -*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which -triggers the fx lifecycle hooks in dependency order. The -`delivery.Notifier` interface allows the webhook handler to send -self-contained `delivery.Task` slices to the engine without a direct -package dependency. Each task carries all target config and event data -inline (for bodies under 16 KiB, `delivery.MaxInlineBodySize`), so the -engine can deliver without reading from any database — it only writes -to record results. +The server starts via +`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper) {})`, +which triggers the fx lifecycle hooks in dependency order. The +`delivery.Notifier` interface allows the webhook handler to send self-contained +`delivery.Task` slices to the engine without a direct package dependency. Each +task carries all target config and event data inline (for bodies under 16 KiB, +`delivery.MaxInlineBodySize`), so the engine can deliver without reading from +any database — it only writes to record results. ### Middleware Stack Applied to all routes in this order: 1. **RequestID** — Generate unique request IDs (chi built-in) -2. **SecurityHeaders** — Production security headers on every response - (HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, - Permissions-Policy) -3. **Logging** — Structured request logging (method, URL, status, - latency, remote IP, user agent, request ID) +2. **SecurityHeaders** — Production security headers on every response (HSTS + unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP, + Referrer-Policy, Permissions-Policy) +3. **Logging** — Structured request logging (method, URL, status, latency, + remote IP, client IP, user agent, request ID) 4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and `METRICS_PASSWORD` are both set) 5. **CORS** — Cross-origin resource sharing headers 6. **Timeout** — 60-second request timeout -7. **Recoverer** — Panic recovery: one `ERROR` record through - `internal/logger` and a `500` -8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; - configured with `Repanic: true` so panics still reach Recoverer) +7. **Recoverer** — Panic recovery: one `ERROR` record through `internal/logger` + and a `500` +8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; configured + with `Repanic: true` so panics still reach Recoverer), and inside it the + naming of the request's Sentry transaction after its route pattern -Recoverer sits seventh rather than first, and both neighbours are the -reason. It runs **inside** everything that observes the response, so -the `500` it writes for a panicking handler is the status the access -log records and the metrics count; registered first, as chi's own -`middleware.Recoverer` was, the same request was logged as a `200` that -the client never received. It runs **outside** the Sentry handler, so -`Repanic: true` has something to re-raise into: an operator with -`SENTRY_DSN` set keeps the report, and one without it now gets the -local record instead of nothing. What that placement gives up is -recovery of a panic in the six entries above it, none of which does -more than set a header or start a timer. +Recoverer sits seventh rather than first, and both neighbours are the reason. It +runs **inside** everything that observes the response, so the `500` it writes +for a panicking handler is the status the access log records and the metrics +count; registered first, as chi's own `middleware.Recoverer` was, the same +request was logged as a `200` that the client never received. It runs +**outside** the Sentry handler, so `Repanic: true` has something to re-raise +into: an operator with `SENTRY_DSN` set keeps the report, and one without it now +gets the local record instead of nothing. What that placement gives up is +recovery of a panic in the six entries above it, none of which does more than +set a header or start a timer. -Additionally, form endpoints (`/pages`, `/user/*`, `/sources`, -`/source/*`) apply a **MaxBodySize** middleware that limits -POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the -CSRF middleware in every one of those route groups, because -gorilla/csrf parses the form; if the cap were installed after it, form -parsing would run under net/http's 10 MB default and the 1 MB limit -would never apply. A request that declares a `Content-Length` over the -limit is answered with `413 Request Entity Too Large` without its body -being read and without reaching CSRF, the route group's remaining -middleware, or the handler. It is not rejected before *any* other -middleware, though: the global entries listed above all run first, so -such a request is still logged and given the security headers — and -counted in the metrics, on a deployment where the `/metrics` -credentials are set and the Metrics middleware is therefore registered -at all. The -rejection itself is logged at `WARN` with the method, path and -declared length. A chunked request, or -one that lies about its length, is hard-capped by -`http.MaxBytesReader` and fails downstream at form-parse time. +Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`, +`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its +own **Sentry** error reporting and transaction naming. That Recoverer answers a +panic with the `500` error page in the normal layout; the global one keeps the +plain-text `500` for every other route. -Those same four route groups then apply **CSRF** and **NoCache** -(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except -`/pages` applies **RequireAuth**. The rate limiters are per-route -rather than global: **PasswordChangeRateLimit** on -`/user/{username}/password` and **ReceiverRateLimit** on -`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that -endpoint counts failures inside the handler, after the credential -check, see [The login endpoint](#the-login-endpoint). +Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, `/hooks`, +`/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request +bodies to 1 MB. It is registered ahead of the CSRF middleware in every one of +those route groups, because gorilla/csrf parses the form; if the cap were +installed after it, form parsing would run under net/http's 10 MB default and +the 1 MB limit would never apply. A request that declares a `Content-Length` +over the limit is answered with `413 Request Entity Too Large` without its body +being read and without reaching CSRF, the route group's remaining middleware, or +the handler. It is not rejected before _any_ other middleware, though: the +global entries listed above all run first, so such a request is still logged and +given the security headers — and counted in the metrics, on a deployment where +the `/metrics` credentials are set and the Metrics middleware is therefore +registered at all. The rejection itself is logged at `WARN` with the method, +path and declared length. A chunked request, or one that lies about its length, +is hard-capped by `http.MaxBytesReader` and fails downstream at form-parse time. + +Those same five route groups then apply **CSRF** and **NoCache** +(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except `/pages` +applies **RequireAuth**. The rate limiters are per-route rather than global: +**PasswordChangeRateLimit** on `/user/{username}/password` and +**ReceiverRateLimit** on `/h/{uuid}`. There is deliberately none on +`/pages/login` — that endpoint counts failures inside the handler, after the +credential check, see [The login endpoint](#the-login-endpoint). ### Authentication -- **Webhook receiver:** the entrypoint UUID in the URL, and nothing - else. No shared secret, no HMAC signature, no token header, and none - will be added — see +- **Webhook receiver:** the entrypoint UUID in the URL, and nothing else. No + shared secret, no HMAC signature, no token header, and none will be added — + see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret). -- **Web UI:** Cookie-based sessions using gorilla/sessions with - encrypted cookies. Sessions are configured with HttpOnly, SameSite - Lax, and Secure whenever the request is on TLS — the flag follows the - request's transport, not the environment. Absolute session lifetime - is 7 days, with a sliding idle timeout on top of it (see - [Sessions](#sessions)). -- **API (planned):** API key authentication via `Authorization: Bearer` - header. API keys are stored per-user with usage tracking - (`last_used_at`). +- **Web UI:** Cookie-based sessions using gorilla/sessions with encrypted + cookies. Sessions are configured with HttpOnly, SameSite Lax, and Secure + whenever the request is on TLS — the flag follows the request's transport, not + the environment. Absolute session lifetime is 7 days, with a sliding idle + timeout on top of it (see [Sessions](#sessions)). +- **API (planned):** API key authentication via `Authorization: Bearer` header. + API keys are stored per-user with usage tracking (`last_used_at`). - **Metrics:** Basic authentication protecting the `/metrics` endpoint. -- **Recovery:** `webhooker resetpw ` on a stopped deployment - is the only way back into an account whose password was lost (see +- **Recovery:** `webhooker resetpw ` on a stopped deployment is the + only way back into an account whose password was lost (see [Recovering a lost admin password](#recovering-a-lost-admin-password)). ### Security - Passwords hashed with Argon2id (64 MB memory cost) -- Session cookies are HttpOnly, SameSite Lax, and Secure on any request - that arrived over TLS (directly or through a reverse proxy reporting - it), decided per-request by `internal/reqtls.IsTLS` rather than by the - configured environment +- Session cookies are HttpOnly, SameSite Lax, and Secure on any request that + arrived over TLS (directly or through a reverse proxy reporting it), decided + per-request by `internal/reqtls.IsTLS` rather than by the configured + environment - Session regeneration on login to prevent session fixation attacks -- Session key is a 32-byte value auto-generated on first startup and - stored in the database -- Production security headers on all responses: HSTS, X-Content-Type-Options - (`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy, - and Permissions-Policy -- Request body size limits (1 MB) on all form POST endpoints, enforced - by middleware that runs before CSRF parses the form -- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) - on all state-changing forms (cookie-based double-submit tokens with - HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and - `/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and - `/api` (stateless API). The middleware detects TLS per-request through - `internal/reqtls.IsTLS` — the same predicate the session cookie uses — - to set appropriate cookie security flags and Origin/Referer validation - mode -- **The entrypoint URL is the receiver's only credential.** Nothing - about an inbound request is verified; possession of the UUID - authorises submission, and no shared secret or signature check will - be added alongside it (see +- Session key is a 32-byte value auto-generated on first startup and stored in + the database +- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is + `0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`), + Content-Security-Policy, Referrer-Policy, and Permissions-Policy +- Request body size limits (1 MB) on all form POST endpoints, enforced by + middleware that runs before CSRF parses the form +- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all + state-changing forms (cookie-based double-submit tokens with HMAC + authentication). Applied to `/pages`, `/hooks`, `/hook`, `/settings`, and + `/user` routes. Excluded from `/h` (inbound webhook POSTs) and `/api` + (stateless API). The middleware detects TLS per-request through + `internal/reqtls.IsTLS` — the same predicate the session cookie uses — to set + appropriate cookie security flags and Origin/Referer validation mode +- **The entrypoint URL is the receiver's only credential.** Nothing about an + inbound request is verified; possession of the UUID authorises submission, and + no shared secret or signature check will be added alongside it (see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)) -- **SSRF prevention** for HTTP delivery targets: private/reserved IP - ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked - both at target creation time (URL validation) and at delivery time - (custom HTTP transport with SSRF-safe dialer that validates resolved - IPs before connecting, preventing DNS rebinding attacks). Both paths - route through a single decision function, so they cannot disagree - about a destination. An operator can permit specific blocks with - [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the - guard cannot be switched off, and link-local plus a - [pinned set](#allowing-egress-to-your-own-network) of known cloud - metadata endpoints — several of which are ULAs outside link-local — - stay blocked whatever is listed, though listing `0.0.0.0/0` or - `::/0` does open every other private range -- **Login limiting is inverted, deliberately.** The login `POST` has - no pre-emptive rate limiter in front of it. Credentials are - verified first and only a _failed_ attempt spends budget, so a - correct password is never throttled and no flood of wrong ones can - deny the operator the only administrative path. Failures are - counted per (bucket, submitted username), five per minute, after - which further failures are answered `429` with a `Retry-After`. - What bounds brute force is not that counter but the cap of two - concurrent Argon2id verifications: a throttled client's guess is - still evaluated, so roughly 27 guesses a second get through and the - admin password has to carry that load (see - [The login endpoint](#the-login-endpoint)). `GET` requests to the - login page are not limited -- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate): - sliding-window rate limiter, 5 POST attempts per minute per bucket. - It runs behind session auth, so only a client already holding a - valid session reaches it, and an operator throttled out of changing - a password can still log in. The bucket is per client IP only when - `TRUSTED_PROXIES` names the reverse proxy; unset, every client - shares one bucket, which costs precision rather than availability - (see [Rate Limiting](#rate-limiting)). webhooker warns at startup - whenever `TRUSTED_PROXIES` is empty +- **SSRF prevention** for HTTP delivery targets: private/reserved IP ranges (RFC + 1918, loopback, link-local, cloud metadata) are blocked both at target + creation time (URL validation) and at delivery time (custom HTTP transport + with SSRF-safe dialer that validates resolved IPs before connecting, + preventing DNS rebinding attacks). Both paths route through a single decision + function, so they cannot disagree about a destination. An operator can permit + specific blocks with + [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the guard + cannot be switched off, and link-local, the unspecified addresses `0.0.0.0` + and `::`, and a [pinned set](#allowing-egress-to-your-own-network) of known + cloud metadata endpoints — several of which are ULAs outside link-local — stay + blocked whatever is listed, though listing `0.0.0.0/0` or `::/0` does open + every other private range +- **Login limiting is inverted, deliberately.** The login `POST` has no + pre-emptive rate limiter in front of it. Credentials are verified first and + only a _failed_ attempt spends budget, so a correct password is never + throttled and no flood of wrong ones can deny the operator the only + administrative path. Failures are counted per (bucket, submitted username), + five per minute, after which further failures are answered `429` with a + `Retry-After`. What bounds brute force is not that counter but the cap of two + concurrent Argon2id verifications: a throttled client's guess is still + evaluated, so roughly 27 guesses a second get through and the admin password + has to carry that load (see [The login endpoint](#the-login-endpoint)). `GET` + requests to the sign-in page are not limited +- **Password-change rate limiting** via + [go-chi/httprate](https://github.com/go-chi/httprate): sliding-window rate + limiter, 5 POST attempts per minute per bucket. It runs behind session auth, + so only a client already holding a valid session reaches it, and an operator + throttled out of changing a password can still log in. The bucket is per + client IP only when `TRUSTED_PROXIES` covers the reverse proxy; otherwise + every client shares one bucket, which costs precision rather than availability + (see [Rate Limiting](#rate-limiting)) - Prometheus metrics behind basic auth -- Static assets embedded in binary (no filesystem access needed at - runtime) -- The app runs as the non-root `webhooker` user (UID 1000) in the - container. The image sets no `USER`, so these run as root: the - `ENTRYPOINT` script, which sets the data directory's owner and mode - before the app starts; the image's health check; and `docker exec`, - unless given `--user` -- GORM soft deletes on every entity that carries `BaseModel`, which is - all of them but `Setting` (data preserved for audit) +- Static assets embedded in binary (no filesystem access needed at runtime) +- The app runs as the non-root `webhooker` user (UID 1000) in the container. The + image sets no `USER`, so these run as root: the `ENTRYPOINT` script, which + sets the data directory's owner and mode before the app starts; the image's + health check; and `docker exec`, unless given `--user` +- GORM soft deletes on every entity that carries `BaseModel`, which is all of + them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals` (data + preserved for audit) ### Shutdown -On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse -dependency order under a **5 second budget** (`fx.StopTimeout` in -`cmd/webhooker/main.go`). That budget covers the whole sequence, not -each hook. The order, read off the fx stop-hook log: +On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse dependency +order under a **5 second budget** (`fx.StopTimeout` in `cmd/webhooker/main.go`). +That budget covers the whole sequence, not each hook. The order, read off the fx +stop-hook log: 1. `ArchiveSweeper` 2. `RetentionReaper` -3. `server` — the HTTP drain, bounded separately by - `server.ShutdownTimeout` (**3 seconds**), then a Sentry flush if +3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 + seconds**) and by what the hooks before it left, then a Sentry flush if `SENTRY_DSN` is set -4. `delivery.Engine` — waits for its workers, then closes the archive - databases +4. `delivery.Engine` — waits for its workers, then closes the archive databases 5. `healthcheck` 6. `WebhookDBManager` 7. the database close -The two components that can realistically hold the budget run -first: a retention sweep or an archive prune caught mid-tick each -waits on its `WaitGroup` bounded by the stop context, so a wedge -there consumes the 5 seconds before the HTTP server hook is ever -entered. The hooks after the server are microsecond-scale in normal -operation. +The two components that can realistically hold the budget run first: a retention +sweep or an archive prune caught mid-tick each waits on its `WaitGroup` bounded +by the stop context, so a wedge there consumes the 5 seconds before the HTTP +server hook is ever entered. The hooks after the server are microsecond-scale in +normal operation. -The HTTP drain budget is deliberately **shorter** than the sequence -budget. Were the two equal, a drain that used its whole budget would -exhaust the sequence budget at the instant it finished, and every -later hook — the delivery engine, the healthcheck, the webhook DB -manager and the database close — would be skipped in exactly the -case where the drain mattered. 3 seconds leaves 2 seconds -(`server.TailHookReserve`) for the tail, which is far more than the -microseconds it needs. +The HTTP drain budget is deliberately **shorter** than the sequence budget. Were +the two equal, a drain that used its whole budget would exhaust the sequence +budget at the instant it finished, and every later hook — the delivery engine, +the healthcheck, the webhook DB manager and the database close — would be +skipped in exactly the case where the drain mattered. 3 seconds leaves 2 seconds +(`server.TailHookReserve`) for the tail. The reserve is that remainder, not a +figure sized to the tail, which takes about a millisecond. -That reserve belongs to the tail hooks, not to the server hook, and -the Sentry flush is what could take it: it runs after the drain -**inside the same hook**, and `sentry.Flush` takes a bare duration -and honours no context, so an unreachable Sentry endpoint would add -its own timeout on top of a full-length drain and consume the whole -sequence budget by itself. It is therefore clamped to whatever is -left on the stop context minus the reserve, and skipped when that -leaves too little to be worth attempting — so a full-length drain -means Sentry events are dropped rather than the database close being +That reserve belongs to the tail hooks, not to the server hook, and the server +hook could take it in two ways. The hooks before it may already have spent part +of the budget, so a full 3-second drain would come out of the reserve; the drain +is therefore also bounded by whatever is left on the stop context minus the +reserve. And the Sentry flush runs after the drain **inside the same hook**, and +`sentry.Flush` takes a bare duration and honours no context, so an unreachable +Sentry endpoint would add its own timeout on top of a full-length drain and +consume the whole sequence budget by itself. It is clamped the same way, and +skipped when that leaves too little to be worth attempting — so a full-length +drain means Sentry events are dropped rather than the database close being skipped. -This does not make the database close unconditional: a wedged -`ArchiveSweeper` or `RetentionReaper` still runs first and can -consume the whole budget on its own. +This does not make the database close unconditional. A slow `ArchiveSweeper` or +`RetentionReaper` is enough to cut the shutdown short, not only one that +consumes the whole budget: what they spend comes out of the drain first, so +after 2 seconds of theirs a request still in flight gets 1 second to finish, and +after 3 it gets none. Past 3 seconds they spend the reserve itself, and one that +takes the whole budget skips every hook after it, the database close included. -The value is chosen to sit inside the container stop grace period. -Docker's default `docker stop` grace is 10 seconds and the Dockerfile -sets no `STOPSIGNAL` or grace override, so the process must be gone -before that. fx's own default is 15 seconds, which is past the grace: -the container would be SIGKILLed (exit 137) before the bound could -fire, and nothing that depends on it — including the -`shutdown timed out, goroutines still running` error log that tells -an operator a component is wedged — would ever be reached. +The value is chosen to sit inside the container stop grace period. Docker's +default `docker stop` grace is 10 seconds and the Dockerfile sets no +`STOPSIGNAL` or grace override, so the process must be gone before that. fx's +own default is 15 seconds, which is past the grace: the container would be +SIGKILLed (exit 137) before the bound could fire, and nothing that depends on it +— including the `shutdown timed out, goroutines still running` error log that +tells an operator a component is wedged — would ever be reached. Two operational consequences follow from bounding the sequence: -- **A wedged component aborts the rest of the shutdown.** fx checks - the stop context before each remaining hook and returns outright - once it has expired, skipping the hooks it has not reached. If the - first-stopped component consumes the whole budget, the later hooks - never run — **the database close among them**. SQLite is crash-safe, - so this is not corruption, but it is not a clean close either. -- **Lowering the grace below 5 seconds reintroduces the silent - truncation.** `docker stop --time`, Compose's `stop_grace_period`, - or Kubernetes' `terminationGracePeriodSeconds` set under 5 seconds - put SIGKILL back in front of the bound, and the process dies with - no shutdown diagnostics at all. Keep the deployment's grace above - the stop timeout. +- **A wedged component aborts the rest of the shutdown.** fx checks the stop + context before each remaining hook and returns outright once it has expired, + skipping the hooks it has not reached. If the first-stopped component consumes + the whole budget, the later hooks never run — **the database close among + them**. SQLite is crash-safe, so this is not corruption, but it is not a clean + close either. +- **Lowering the grace below 5 seconds reintroduces the silent truncation.** + `docker stop --time`, Compose's `stop_grace_period`, or Kubernetes' + `terminationGracePeriodSeconds` set under 5 seconds put SIGKILL back in front + of the bound, and the process dies with no shutdown diagnostics at all. Keep + the deployment's grace above the stop timeout. ### Linting -golangci-lint never runs on the host. `script/lint` builds -`Dockerfile.lint`, which copies the repo into the digest-pinned -golangci-lint image and lints as a build step, so a successful build is -a clean lint. A host binary would share one cache and one lock with -every other checkout on the machine, which has produced both invented -findings attributed to other worktrees and unearned passes. +golangci-lint never runs on the host. `script/lint` builds the Dockerfile's +`lint` phase, which copies the repo into the digest-pinned golangci-lint image +and lints as a build step, so a successful build is a clean lint. A host binary +would share one cache and one lock with every other checkout on the machine, +which has produced both invented findings attributed to other worktrees and +unearned passes. -Three properties are load-bearing: +Two properties are load-bearing: -- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged - tree replays the lint layer from cache and the build exits 0 in under - a second having linted nothing. The `deps` stage stays cacheable, so - module downloads are not repeated. Invalidation is scoped to the one - stage; never prune the shared build cache. -- `script/lint` does not trust that flag. Docker silently ignores - `--no-cache-filter` for a stage name that does not match, so a stage - rename or a one-character typo would restore the cached false green - with no warning and a fast exit 0. The script therefore tees the - build output and treats a run as a pass only if golangci-lint's own - summary line (`N issues.` / `N issues:`) appears in it: no summary, - no lint, whatever the exit code says. -- Both lint steps use `RUN --network=none`. `golangci-lint config - verify` is documented as fetching its JSON schema over HTTPS, which - would be an unpinned remote dependency; the pinned image resolves the - schema without network access, and `--network=none` enforces that - instead of trusting it. Verify is worth keeping because - `golangci-lint run` silently ignores config keys it does not - recognize, so a typo would disable a setting with no warning. +- `script/lint` passes `--no-cache`. Without it an unchanged tree replays the + lint layer from cache and the build exits 0 in under a second having linted + nothing. Never prune the shared build cache instead. +- Both golangci-lint steps use `RUN --network=none`. + `golangci-lint config verify` is documented as fetching its JSON schema over + HTTPS, which would be an unpinned remote dependency; the pinned image resolves + the schema without network access, and `--network=none` enforces that instead + of trusting it. Verify is worth keeping because `golangci-lint run` silently + ignores config keys it does not recognize, so a typo would disable a setting + with no warning. + +ESLint never runs on the host either. It lints `static/js/` (not the extracted +Alpine.js) in the Dockerfile's `js-lint` stage. The `lint` phase copies a file +from it, so `make lint` and the image build both run ESLint. Its version is +pinned in `package.json` and every package's hash in `yarn.lock`. The `js-deps` +stage before it installs ESLint with `yarn install --immutable`, which fails +rather than change `yarn.lock`. The yarn it runs is the one the `packageManager` +field in `package.json` pins by version and hash, which the node image's own +corepack fetches and checks. `eslint.config.mjs` turns on the rules of the +JavaScript styleguide `REPO_POLICIES.md` links to that a linter can check: +`no-var` and `prefer-const`. + +prettier formats the Markdown, and it never runs on the host either. It is +pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same +`js-deps` stage, and reads its settings from `.prettierrc`. `make fmt` builds +the `markdown-output` stage and writes the formatted files back into the tree; +`make fmt-check` and the image build run the `markdown-check` stage, which fails +on any Markdown file prettier would change. ### Docker -The Dockerfile uses a three-stage build. Each stage is pinned by -digest, and the two check stages are separate images so the linter's -version is fixed independently of the compiler's: +The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the +lint phase and the Go stages are separate images so the linter's version is +fixed independently of the compiler's: -1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — - installs `make`, downloads dependencies, copies the source, and runs - `make fmt-check`, then `golangci-lint config verify` and - `golangci-lint run`, both with `--network=none`. -2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint - stage passing (it copies a file from it), runs `script/fetch-assets` - to download and verify the third-party browser assets, then runs - `make test` and `make build`, and finally rebuilds the binary with - `CGO_ENABLED=1` and static linking so it runs on musl. Both builds - go through `make build`, the relink adding its `-extldflags` via - `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version. - The version arrives as the `VERSION` build arg, since the context - has no `.git` (see [Version stamping](#version-stamping)). -3. **Runtime stage** (`alpine:3.21`) — copies the static binary and - `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` - directory for all SQLite databases, exposes port 8080, and includes - a health check against `/.well-known/healthcheck`. It sets no - `USER`: the `ENTRYPOINT` script starts as root, sets the data - directory's owner and mode, and runs the app as the non-root - `webhooker` user (UID 1000) through `su-exec`. +1. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone + CLI pinned by version and sha256, one binary per architecture) — generate + `static/css/tailwind.css` from `static/css/input.css` and the files its + `@source` lines name. `css-check` fails when the committed file differs from + the generated one, and `make css` writes the generated file out from + `css-output` (see [Stylesheet](#stylesheet)). +2. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn + `package.json` pins, run through the image's corepack) — `js-deps` installs + ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over + `static/js/` (see [Linting](#linting)). +3. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the + Markdown and fails on any file it would change, and `make fmt` writes the + formatted files out from `markdown-output`. +4. **Lint phase** (`lint`, `golangci/golangci-lint:v2.14.0`, Debian-based) — + downloads dependencies, copies the source, and runs the `gofmt` check, then + `script/assets` to extract Alpine.js from `3p/`, then + `golangci-lint config verify` and `golangci-lint run`, both with + `--network=none`, and depends on `js-lint` (it copies a file from it). + `make lint` builds this stage alone. +5. **Test phase** (`test`, `golang:1.26.1-bookworm`, whose C compiler `-race` + needs) — extracts Alpine.js, then runs `go test -race -cover` at most four + packages and eight tests at a time, with a 90-second timeout per package. On + a failure it runs only the failed tests again with `-v`, since verbose output + from the whole suite would pass the 2 MiB at which the Docker build cuts off + a step's log, and then fails. `make test` builds this stage alone. +6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test + phases and the `css-check` and `markdown-check` stages passing (it copies a + file from each), runs `make build` (which extracts Alpine.js from `3p/` + first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking + so it runs on musl, with the build tags `netgo`, `osusergo` and + `sqlite_omit_load_extension` so it needs none of glibc's shared libraries at + run time. Both builds go through `make build`, the relink adding its + `-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can + drop the `-X` that stamps the version. The version is the `VERSION` build arg + if one is given, otherwise derived from the `.git` in the context, and the + stage fails if a context with `.git` would stamp an empty version, `dev` or + `unknown` (see [Version stamping](#version-stamping)). +7. **Runtime stage** (`alpine:3.21`) — copies the static binary and + `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for + all SQLite databases, exposes port 8080, and includes a health check against + `/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts + as root, sets the data directory's owner and mode, and runs the app as the + non-root `webhooker` user (UID 1000) through `su-exec`. -The lint stage invokes `golangci-lint` directly rather than `make lint`: -it is already the pinned linter image, and `make lint` builds -`Dockerfile.lint`, which would need a docker daemon inside this build. +The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly +rather than `make fmt-check`, `make lint` and `make test`: those targets build +docker stages, which would need a docker daemon inside this build. -Both check stages use Debian rather than Alpine because -`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO -and does not compile against musl. Only the final binary is statically -linked, which is what lets it run on the Alpine runtime image. +The lint phase and the Go stages use Debian rather than Alpine because +`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does +not compile against musl. Only the final binary is statically linked, which is +what lets it run on the Alpine runtime image. -`script/cibuild` — `docker build .` — is the CI gate: the checks run -inside the image, so a build that succeeds is a repo that is formatted, -linted, tested and compiled. `script/lint` also uses Docker -(`Dockerfile.lint`, see Linting above), so `make lint` and `make check` -run the same pinned linter version the gate does; only `script/test` -and `script/fmt-check` run on the host. +`script/cibuild` is the CI gate: it runs `script/bootstrap`, then +`script/check`, then builds the image, whose build runs the lint and test phases +and the stylesheet and Markdown checks again. A build that succeeds is a repo +that is formatted, linted, tested and compiled, with a current stylesheet. +`make check` runs the same stages the gate does; of its steps, only the `gofmt` +check in `script/fmt-check` runs on the host. #### CI gate honesty -A layer cache lets `docker build .` exit 0 in seconds with the lint and -test stages replayed rather than executed, which would make a green -check meaningless. The `check` workflow therefore writes -`.ci-fingerprint` into the build context before building. Its value is -the hash of the last commit that touched the build context, so: +A layer cache lets `docker build .` exit 0 in seconds with the lint and test +stages replayed rather than executed, which would make a green check +meaningless. Every `docker build` in `script/` therefore passes `--no-cache`, so +on every run the `gofmt` check, `golangci-lint`, ESLint, the stylesheet check, +the Markdown check, `go test` and `make build` really execute. A run that +reports success ran them. A bare `docker build .` carries no such guarantee. -- Any commit that changes code (including a squash merge whose tree - matches an already-built branch) gets a new fingerprint, invalidates - the `COPY . .` layer of both check stages, and really runs - `make fmt-check`, `golangci-lint`, `make test`, and `make build`. A - run that reports success ran them. -- A docs-only commit leaves the fingerprint unchanged — `.dockerignore` - excludes `*.md`, `LICENSE` and `.editorconfig` from the context - anyway — so the image replays from cache and costs seconds. - -The module download layer sits above `COPY . .` and stays cached either -way. - -A separate workflow step, run before the fingerprint is written, covers -a second way the gate lied: Gitea cancels an in-flight run when a newer -commit lands on the same branch and records that cancellation as a -`failure` status, so a commit nothing ever tested reads as a test -result. Cancellation is unconditional server-side for push events, so -the superseding run calls `script/ci-mark-superseded`, which rewrites -that exact status to `failure` / -`Superseded by a newer commit; never tested`. - -The state stays `failure` on purpose: Gitea's combined status folds -`skipped` into `success`, so marking a never-tested commit `skipped` -made the status API report green for it, indistinguishable from a commit -that passed. Reading a commit's status on this repo therefore goes: - -- `success` / `Successful in ...` — the checks ran and passed. -- `failure` / `Failing after ...` — the checks ran and failed. -- `failure` / `Superseded by a newer commit; never tested` — the run was - cancelled, by a newer push or by hand, and nothing was verified about - this commit. Test the commit itself before concluding anything about - it. - -Genuine failures and successes are never touched, and no status is left -`pending`, which would block the commit indefinitely. The step derives -its context string from the workflow name, the job **id** and the event. -That is deliberately not byte-identical to Gitea's own rule, which uses -the job's display `name:` where the runner exports the id, so giving the -job a `name:` — or renaming the workflow — makes the derived context -stop matching. The step fails loudly when no status on the commit -carries that context, so no rename can silently disable the rewrite. +The `check` workflow is the shared one from `REPO_POLICIES.md`: it checks out +the repository and runs `script/cibuild`, nothing else. Gitea cancels an +in-flight run when a newer commit lands on the same branch and records that as +`failure` / `Has been cancelled`: nothing was verified about that commit, so +test the commit itself before concluding anything about it. ## TODO @@ -3254,5 +3476,3 @@ MIT ## Author [@sneak](https://sneak.berlin) - - diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 9aba6b0..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-07 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -89,87 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. Dockerfiles install development - prerequisites by running `script/bootstrap` rather than duplicating installs - inline; COPY `script/` and the dependency manifests (`package.json` + - `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap - layer stays cached until dependencies change. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. - If the project uses `//go:embed` directives that reference build artifacts - (e.g. a web frontend compiled in a separate stage), the lint stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -193,15 +315,17 @@ style conventions are in separate documents: suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test - invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately - sits above the hard cap so that it catches a genuinely hung test rather than a - merely slow one. + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`make test` should use the conditional verbose rerun pattern.** Run tests - without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to - show full output. This keeps CI logs and `docker build` output clean on - success (just package/suite summaries) while providing full diagnostic detail - on failure (every test case, every assertion). The general shell pattern: +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -214,11 +338,26 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 90s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + Python example: ```makefile @@ -244,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -263,12 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The - canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed - commit-pinned via - `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -382,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -414,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/TODO.md b/TODO.md index 3da085d..6929c87 100644 --- a/TODO.md +++ b/TODO.md @@ -2,424 +2,71 @@ One issue per unit of work, one branch and one PR per issue: -* ensure a tracked issue exists with a definition of done -* branch from `next` (never from `main`) -* do the work; open a PR based on `next` (never on `main`) -* pass an independent review, then the manager squash-merges into `next` -* push; nothing stays local-only +- ensure a tracked issue exists with a definition of done +- branch from `next` (never from `main`) +- do the work; open a PR based on `next` (never on `main`) +- pass an independent review, then the manager squash-merges into `next` +- push; nothing stays local-only -`next` is the branch for the next milestone and must stay green and -mergeable to `main` without notice. One `next` -> `main` PR accumulates -the milestone; releases are cut from `main` separately. +`next` is the branch for the next milestone and must stay green and mergeable to +`main` without notice. One `next` -> `main` PR accumulates the milestone; +releases are cut from `main` separately. -Issue branches do NOT touch this file — the manager maintains it on -`next`. Every branch editing `TODO.md` conflicts with every other -(#112). +The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what +is done and what is under way. This file keeps no list of either. # Status -The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the -authoritative list, and the only place to read a count or a state of -play from. This file records where the project is, not what is in -flight: a sentence whose truth depends on a branch being unmerged is -wrong the moment it merges, and this file has been wrong that way -before. +What is still open for 1.0.0 is in its milestone +(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and +only @sneak tags it. -The durability defect that held the tag has landed -(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). -Every SQLite handle opens with WAL journaling and a busy timeout, a -bookkeeping write that fails leaves its delivery in a recoverable -state rather than a lying one, and recovery skips a delivery that -already has a successful result row. Final pre-tag verification -exercised it and confirmed it holds. Whatever the milestone still -shows open is what remains before `v1.0.0`. - -Delivery is at-least-once by design, not by accident: a send whose -result row does not land is attempted again, so a receiver can see a -duplicate. That is deliberate — the alternative is a silent lost -delivery — and the README says so under Rationale. It is not a defect -to re-file. - -One caveat on reading a green check: a docs-only commit deliberately -replays from the layer cache -(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on -such a commit evidences a replay rather than an executed run. A code -commit invalidates the `COPY` layer and genuinely executes. +Delivery is at-least-once by design, not by accident: a send whose result row +does not land is attempted again, so a receiver can see a duplicate. That is +deliberate — the alternative is a silent lost delivery — and the README says so +under Rationale. It is not a defect to re-file. # Next Step -Clear the rest of the open 1.0.0 milestone -(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. -Merging `next` into `main` is a separate act from tagging and waits on -neither of those: `next` is kept mergeable at all times, which is the -point of the branch. - -# Completed Steps - -- 2026-08-24 Bind the plaintext HTTP listener deliberately, via - `BIND_ADDRESS` defaulting to `127.0.0.1`, and document the - reverse-proxy deployment. A hostname, an empty value or a value - carrying a port is a startup error, and the `Dockerfile` sets - `0.0.0.0` because a loopback bind inside a container is unreachable - (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit - removed the shutdown race: `httpServer` is built in the constructor - rather than assigned from the serving goroutine, which orders the - write before every fx hook and rules out the nil dereference a - SIGTERM arriving first would have caused, and `sentryEnabled` is an - `atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226) -- 2026-08-24 Remove inbound request signature verification. The - entrypoint UUID is the authentication secret, so the per-entrypoint - shared secret, the `internal/signature` package, the receiver check, - the model fields and the forms are all gone. This reverses the - feature that landed earlier in the same milestone - (https://git.eeqj.de/sneak/webhooker/issues/67, - https://git.eeqj.de/sneak/webhooker/issues/279) -- 2026-08-24 Stamp the build version into the binary and render it in - the UI footer. `script/version` is the single source — `$VERSION`, - else `git describe --tags --always --dirty`, else `unknown` — so a - `make build` binary and a `make docker` image from one checkout - report the same thing, and nothing in it varies between two builds - of the same commit, which the release gate's byte-identical - assertion would catch - (https://git.eeqj.de/sneak/webhooker/issues/253) -- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the - request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a - real TLS proxy with the environment left at its `dev` default, the - session cookie silently lost `Secure` while the CSRF cookie on the - same response kept it. `X-Forwarded-Proto` is now matched - case-insensitively on its first comma-separated element, so `HTTPS` - and `https, http` no longer fall to the relaxed CSRF path - (https://git.eeqj.de/sneak/webhooker/issues/269) -- 2026-08-24 Roll back a failed webhook deletion instead of committing - it. A failing delete committed whatever had already succeeded, - hard-deleted the per-webhook event database anyway, and redirected as - though it had worked — orphaned config plus permanently destroyed - history, reported as success. All three delete positions now roll - back with the event database intact - (https://git.eeqj.de/sneak/webhooker/issues/262) -- 2026-08-24 Name a deleted target on its historical deliveries, marked - `(deleted)`, rather than leaving the event log unable to say where a - delivery went. A deleted target's credentials stay masked exactly as - a live one's, and it cannot become deliverable again through the - receiver, resubmit, replay, the edit form or the toggle - (https://git.eeqj.de/sneak/webhooker/issues/211) -- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, - so the unauthenticated receiver is no longer a memory-exhaustion - vector: `handler` carries the chi route pattern, and `method` folds - anything chi cannot route onto a single `(unmatched)` sentinel. Both - were reproduced before the fix — 300 random method tokens took the - series count from 106 to 7,631, and path flooding reached 62,532 — - and a label audit across a live scrape found no third unbounded - dimension (https://git.eeqj.de/sneak/webhooker/issues/254, - https://git.eeqj.de/sneak/webhooker/issues/261) -- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` - and `-5` silently became 0 — fire-and-forget — including on the edit - path, where it destroyed a working value, and `999999999` stored - verbatim. The ceiling of 20 is the `max` both templates already - declared (https://git.eeqj.de/sneak/webhooker/issues/221) -- 2026-08-24 Resubmit a stored event as a new undelivered event, so a - backend under development can be tested against real captured - traffic. Per-delivery replay cannot serve that: it re-sends one - finished delivery to its own original target, and a target created - for a dev backend has no prior delivery to replay. Resubmit - re-injects the stored event at the top of the receiver path and fans - it out to whatever targets are active now - (https://git.eeqj.de/sneak/webhooker/issues/250) -- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two - instances on one directory cannot both deliver - (https://git.eeqj.de/sneak/webhooker/issues/201) -- 2026-08-20 Shut down the app when the HTTP listener fails. The - `OnStart` hook returned as soon as the serving goroutine was - spawned, so a failed listen left fx reporting RUNNING and a live - process with nothing bound — invisible to systemd and Docker restart - policies (https://git.eeqj.de/sneak/webhooker/issues/200) -- 2026-08-20 Stop target credentials leaking into the per-webhook event - databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL - with placeholders rather than bound values - (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on - half-set metrics auth credentials - (https://git.eeqj.de/sneak/webhooker/issues/205) -- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps - GORM's own trace recorder in for the logging adapter, and that - recorder does not implement `gorm.ParamsFilter`, so those statements - logged their bound values interpolated and bypassed the suppression - above. The two units gated green against a `next` that lacked the - other, and `next` went red when both landed - (https://git.eeqj.de/sneak/webhooker/issues/234) -- 2026-08-20 Render per-attempt delivery detail in the event log - (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a - terminally failed delivery - (https://git.eeqj.de/sneak/webhooker/issues/203) -- 2026-08-20 Expose delivery metrics on `/metrics` - (https://git.eeqj.de/sneak/webhooker/issues/209) and document the - backup, restore and upgrade procedures - (https://git.eeqj.de/sneak/webhooker/issues/210) -- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap - banner. The admin bootstrap password was printed once among roughly - 45 fx lines, and under `docker run -d` went to container logs subject - to rotation; there was no reset path at all, so recovery meant - hand-deleting the users row, documented nowhere. The password is read - from stdin or generated, never from argv where `/proc` would publish - it (https://git.eeqj.de/sneak/webhooker/issues/208) -- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch - for the SSRF guard, so a self-hosted proxy can forward into the - operator's own network. The guard's always-blocked set cannot be - reopened by configuration - (https://git.eeqj.de/sneak/webhooker/issues/204) -- 2026-08-20 Harden operator-set target headers, which were carried - unsafely across a redirect - (https://git.eeqj.de/sneak/webhooker/issues/233) -- 2026-08-20 Add a target edit form with headers and timeout fields - (https://git.eeqj.de/sneak/webhooker/issues/127) -- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, - matching the org-wide backstop. `go test` applies `-timeout` per - package, and `internal/handlers` had grown past the old budget: a - cache-defeated build failed outright at `GOMAXPROCS=4`, and every run - under deliberate host load breached 30s. The measurement table lives - in the script (#194) -- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy - was stale and still mandated a 20s test target with a 30s timeout, - which the org replaced with a 60s cap and a 90s backstop. A synced - copy is not a source; reading it as one nearly produced a PR against - `prompts` proposing a change already merged there (#196) -- 2026-08-18 Report handler panics through the logger and answer 500. - chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no - longer emits, then indexes `pkg[-1:]`, so it panicked inside its own - stack printer before writing a byte: the recovery never ran, the - client got a dropped connection instead of a 500, and the original - panic was lost. A local middleware replaces it, bounded by - `MaxPanicLogLineBytes` (#187) -- 2026-08-18 Route GORM's logger through `slog` and bound it. Every - `gorm.Open` left `logger.Default` in place at `Warn` with - `IgnoreRecordNotFoundError` false, so **every record-not-found - printed the fully interpolated SQL to stdout** — including the - client-chosen path on `/webhook/{uuid}` and the submitted username on - the login form, at no level the operator set and outside - `internal/logger` entirely. Three call sites, not the two the issue - named (#178) -- 2026-08-18 Bound every `slog` line against client-chosen text. Eight - sites reachable unauthenticated, found by reading every `slog` call in - the tree rather than only the one reported; the budget moved to a - shared `internal/logfield` so no second truncation exists. `DEBUG` - being off by default is not a bound and is not treated as one (#176) -- 2026-08-18 Stop a slow host turning a login-guard test into a - segfault. A non-fatal `assert` on an acquire result was dereferenced - on the next line, so one timing miss killed the whole - `internal/middleware` binary and reddened CI for unrelated PRs. The - fix also removed a real production race — `acquire` could shed a - request with a slot standing free, because Go picks uniformly among - ready `select` cases (#186) -- 2026-08-18 Send the chi route pattern to Sentry rather than the - concrete path. The receiver's path carries the entrypoint capability - token, so every Sentry event from `/webhook/{uuid}` shipped a live - credential to a third party. Request `Data`, `QueryString`, `Cookies` - and `Env` are dropped and headers reduced to an allowlist (#179) -- 2026-08-18 Read form fields from the POST body only. `r.FormValue` - merges the query string, so a login could be driven by URL parameters - — putting the password somewhere that lands in access logs, proxy - logs and browser history (#160) -- 2026-08-18 Verify login credentials before spending rate-limit - budget, so a flood of wrong passwords cannot lock out the account it - is guessing at. The manager took this decision rather than stall the - queue; it is flagged on the issue for reversal (#150) -- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint - was wrong in both directions from version skew and shared caches. - `script/lint` asserts the summary line, because `--no-cache-filter` - silently ignores a stage name it does not match — the flag that makes - the gate meaningful fails open (#109) -- 2026-08-18 Serve an event's full stored body over HTTP. The list - query truncates for rendering, and that truncated value was the only - way to read a body, so the full payload was unreachable (#157) -- 2026-08-18 Bound the access log line against client-chosen text. - `internal/logfield` budgets by *encoded* bytes, not runes, so a - handler's JSON escaping cannot multiply a field past its allowance - (#146) -- 2026-08-18 Mark superseded CI commits `failure` rather than - `skipped`. A skipped run rolls up green, so a commit that was never - tested reported success (#152) -- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so - shutdown hooks are bounded by a deadline the orchestrator will - actually honour rather than being killed mid-flush (#134) -- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation - hands out 2^64 addresses, so per-address keying let one client mint - unlimited buckets. Manager decision, recorded on the issue (#125) -- 2026-08-17 Correct release-blocking README and startup-warning - inaccuracies, including claims about behaviour the code does not have - (#151) -- 2026-08-17 Fetch and verify Alpine.js at build time against - `static/vendor.sha256` instead of committing the minified blob, so - the dependency is pinned by hash rather than by trust (#145) -- 2026-08-17 Bound the event log's rendered bodies in the query itself, - so a large stored payload cannot be read into memory just to be - truncated for display (#135) -- 2026-08-17 Mask the `http` target's destination URL in the UI: it can - carry a bearer credential in its path or query, and was rendered - verbatim. Manager decision to mask unconditionally (#115) -- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that - hangs cannot hold the process past its grace period (#102) -- 2026-08-14 Render templates via a buffer rather than the - `ResponseWriter`, so a template error part-way through cannot commit - a 200 and then fail — the response is written only once it is whole - (#123) -- 2026-08-14 Align the session codec's max-age with the 7-day absolute - cap. The codec accepted cookies the session layer considered expired, - so the cap was enforced in one place and not the other (#108) -- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where - the safe default silently discards forwarded headers and every client - rate-limits as the proxy's address (#149) -- 2026-08-12 Bound the receiver rate limit per client IP across the - whole `/webhook/*` route. The existing limiter keyed on the request - path and `/webhook/{uuid}` matches any single segment, so a client - that invented a fresh path per request minted a fresh bucket per - request: the limit on the only unauthenticated endpoint bounded - nothing in aggregate, and every request still cost an entrypoint - lookup before it 404ed. An outer limiter keyed on the client address - alone now bounds that, chained in front of the unchanged - per-entrypoint limiter (#139) -- 2026-08-12 Correct release-blocking documentation inaccuracies: the - README promised manual redelivery in the present tense in three - places when nothing implements it (the same false claim also sat in - the doc comment that was its source text), the env table omitted - `RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed - units (#141) -- 2026-08-12 Make the CI gate execute the checks it reports on. The - workflow now writes a build-context fingerprint before calling - `script/cibuild`, so a code commit invalidates the `COPY` layer of - the lint and builder stages while a docs-only commit still replays - from cache; a superseding run also rewrites the `failure` status - Gitea leaves on commits it cancelled and never tested. Verified by - pushing a deliberately broken test and watching CI go red (#119) -- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a - non-positive value reached `time.NewTicker` in both the retention - reaper and the archive sweeper, panicking two goroutines with no - recover after startup had already reported success (#140) -- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop - cap: the reverse walk cuts entries with `strings.LastIndexByte` - instead of joining and splitting, so a 1 MB header allocates 16 bytes - rather than 1.6 MB per request on the unauthenticated receiver. - Semantics proven unchanged by differential testing against the - previous implementation (#133) -- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - attacker-supplied chain cannot burn unbounded CPU in the rate-limit - key function; running off the end falls back to the peer address - (#124) -- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR - list: all three rate limiters key on the connection's own address - unless the direct peer is a configured proxy, in which case - `X-Forwarded-For` is walked right to left for the first non-proxy hop. - Default trusts nothing, and a set-but-unparseable value aborts - startup. Before this, any client could mint a fresh bucket or drain - another's by rotating a spoofed header (#88) -- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the - Profile settings placeholder removed, a progressive-enhancement copy - button for the entrypoint URL, and retention form copy that states the - actual policy (deletion by the reaper, 0 retains forever) (#57) -- 2026-08-11 Mask the webhook credential in delivery errors and logs: - Go embeds the request URL in `*url.Error`, so every transport failure - persisted the full Slack webhook URL into the per-webhook event - database via `DeliveryResult.Error`, a field a future REST API would - have served. `maskURLError` drops path, query and userinfo while - preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()` - still work and DNS, TLS and timeout failures still read differently - (#118) -- 2026-08-11 Rate-limit the public webhook receiver endpoint - (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus - entrypoint path so one entrypoint cannot exhaust another's budget; - over-limit requests get 429 with `Retry-After`. It was the one - unauthenticated, internet-facing endpoint with no limit at all (#64) -- 2026-08-11 Enforce the body size limit before CSRF parses the form: - `MaxBodySize` is now first in all four form-parsing route groups, so - an oversized request is rejected with 413 instead of being read in - full by the CSRF middleware before any cap applied (#90) -- 2026-08-11 Mask target config on the source detail page, which - rendered the stored blob verbatim and so exposed the Slack - incoming-webhook URL — a bearer credential that cannot be revoked - per-holder. Config reaches the template only as a `TargetView` of - labelled fields, and header values are rendered as a count (#113) -- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a - sentinel written in `BeforeSave` so the GORM column default cannot - win the race. Also bounds the reaper's cutoff arithmetic: day counts - above 106751 overflowed `time.Duration` and wrapped the cutoff into - the future, where every row matched and the sweep deleted everything - (#79) -- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated - requests, with the 7-day absolute cap kept as an independent - backstop that activity never extends (#66) -- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - orphaned `retrying` delivery whose target type no longer supports - retries, recording a `DeliveryResult` with the reason instead of - leaving the delivery stuck forever (#82) -- 2026-08-09 Root the delivery engine's worker pool and the retention - reaper's sweep loop at `context.Background()` rather than the fx - `OnStart` hook context (#97), which carries fx's 15s start timeout and - killed both roughly fifteen seconds after boot: the proxy silently - stopped delivering webhooks entirely, and the reaper never ran a - single sweep under its default one-hour interval -- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its - last `database` target) evicts the cached archive writer and closes - its handle while deliberately leaving `archive-{webhookID}.db` on - disk, and a new `ArchiveSweeper` prunes idle archives on the existing - `RETENTION_SWEEP_INTERVAL` without ever creating an archive file -- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - environment values: `envInt` removed in favour of `envPositiveInt` - plus a `PORT` range check, `envBool` now parses with - `strconv.ParseBool`, and defaults apply only to unset variables (#80) -- 2026-08-07 Automatic event retention cleanup based on - `retention_days`, deleting expired events, deliveries, and delivery - results from each per-webhook event database (#63) -- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), - adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so - `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix - all newly surfaced lint findings -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section -- 2026-03-25 pin golangci-lint Docker image for linting (#55) -- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over - plain HTTP and behind reverse proxies (#54) -- 2026-03-17 root path redirects based on auth state (#52) -- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets - with DNS rebinding defense, and per-IP login rate limiting (#42) -- 2026-03-17 Slack target type for incoming webhook notifications (#47) -- 2026-03-17 Dockerfile absolute paths and static linking (#49); - absolute dev DATA_DIR default and clarified env docs (#46) -- 2026-03-05 security headers middleware, session regeneration on - login, request body size limits (#41) -- 2026-03-04 tests for delivery, middleware, and session packages - (#32); removed globals.Buildarch (#31) -- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core - delivery engine with bounded worker pool and circuit breaker, - parallel fan-out, per-webhook event databases, management UI (#16) -- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded - into README (#6) +@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next` +into `main` is a separate act from tagging and waits on neither of those: `next` +is kept mergeable at all times, which is the point of the branch. # Future Steps -- Delivery status and retry management UI. Replay of a terminally - failed delivery and per-attempt detail already landed - (https://git.eeqj.de/sneak/webhooker/issues/203, - https://git.eeqj.de/sneak/webhooker/issues/202) -- Per-webhook rate limiting in the receiver handler (per-webhook config - plus handler enforcement; global limits must not apply to receiver - endpoints) -- API key authentication for programmatic access (APIKey model exists; - Bearer token middleware does not) +- Per-webhook rate limits, set in the web UI and enforced in the receiver + handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT` + sets for the whole server (see Rate Limiting in the README) +- API key authentication for programmatic access (the `APIKey` model exists, but + nothing creates a key and no middleware checks one) - REST API v1 - CRUD for webhooks, entrypoints, targets - event viewing and filtering endpoints - event redelivery endpoint - OpenAPI specification -- Analytics dashboard: success rates, response times, volume -- A remember-me option at login -- Password reset flow for a forgotten password over the web. The - authenticated password *change* flow already landed, and a lost - password is recoverable from the console with `webhooker resetpw` +- Analytics in the web UI: response times summarised per webhook or per target, + and figures for all webhooks together. The webhook page already shows event, + delivery and failure counts and failure percentages, the target list counts + each target's deliveries, the event log and each event's page show every + attempt's duration, and `/metrics` carries server-wide counts and delivery + durations +- A remember-me option at login. Every session already survives a browser + restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT` + (default 24 hours) without activity +- Password reset flow for a forgotten password over the web. The authenticated + password _change_ flow already landed, and a lost password is recoverable from + the console with `webhooker resetpw` (https://git.eeqj.de/sneak/webhooker/issues/208) - Later, nice to have - email delivery target type - SNS and S3 delivery targets - - data transformations (e.g. webhook to Slack message formatting) - - JSONL file delivery with periodic S3 upload - - webhook event search and filtering - - multi-user with role-based access control + - data transformations (e.g. a configurable Slack message format; the Slack + target sends one fixed format today) + - JSONL file delivery with periodic S3 upload (the `database` target already + archives events to SQLite files, rotated hourly, daily or monthly if set, + and the webhook page offers them as one gzipped JSON download) + - webhook event search, and filtering beyond the event log's failed and + pending views + - multi-user with role-based access control (each webhook already belongs to + one account, but there is only the account made at first boot and no way + to add another) diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index 0da8fa0..5990801 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -4,10 +4,12 @@ package main import ( "fmt" "io" + "log/slog" "os" "time" "go.uber.org/fx" + "go.uber.org/fx/fxevent" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/datadir" @@ -16,6 +18,7 @@ import ( "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/server" @@ -36,17 +39,19 @@ import ( // hook that used the whole budget would exhaust it at that instant, // and fx would skip every hook after the server — the delivery // engine, the healthcheck, the webhook DB manager and the database -// close. That hook is the 3s HTTP drain plus the Sentry flush that -// follows it in the same hook, so the flush is clamped to the stop +// close. That hook is the HTTP drain plus the Sentry flush that +// follows it in the same hook, and each is clamped to the stop // context's remaining time less server.TailHookReserve rather than -// running for its own fixed 2s; the reserve is what the tail hooks -// live on, and they are microsecond-scale in normal operation. +// running for its own fixed 3s and 2s; the reserve is what the tail +// hooks live on, and they are microsecond-scale in normal operation. // TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic -// across every drain length. +// across every drain length and every amount of budget the hooks +// before the server may already have spent. // // This does not make the database close unconditional: the -// ArchiveSweeper and RetentionReaper hooks run before the server -// and can still consume the whole budget on their own. +// ArchiveSweeper and RetentionReaper hooks run before the server. +// What they spend comes out of the drain first, but past 3s it comes +// out of the reserve, and they can consume the whole budget. const stopTimeout = 5 * time.Second // exitUsage is the status for a command line this binary cannot make @@ -167,9 +172,25 @@ func run(stderr io.Writer) int { func newApp() *fx.App { return fx.New( fx.StopTimeout(stopTimeout), + // fx's own events go through the service's logger, not fx's + // console logger on standard error. The exception is a failure + // before this logger is built, such as an invalid configuration + // value, which fx's console logger still prints there. fx holds + // its events back until this logger is built and then replays + // them, so it takes the configuration, which sets the level + // DEBUG=true asks for: without it the replay would run at INFO + // and drop every record of how the graph was built. + fx.WithLogger( + func(l *logger.Logger, _ *config.Config) fxevent.Logger { + return logger.NewFxLogger(l.Get()) + }, + ), fx.Provide( globals.New, logger.New, + // The plain logger the session, the middleware and the + // webhook database manager take. + func(l *logger.Logger) *slog.Logger { return l.Get() }, config.New, database.New, database.NewWebhookDBManager, @@ -177,6 +198,10 @@ func newApp() *fx.App { healthcheck.New, session.New, handlers.New, + // The registry /metrics serves, and the delivery + // collectors registered on it. + metrics.NewRegistry, + metrics.New, middleware.New, // The one SSRF guard both target-creation validation // and the delivery dialer consult, so they cannot @@ -187,11 +212,14 @@ func newApp() *fx.App { // Wire *delivery.Engine as delivery.Notifier so the // webhook handler can notify the engine of new deliveries. func(e *delivery.Engine) delivery.Notifier { return e }, - // Wire *delivery.Engine as delivery.WebhookEvictor so - // deleting a webhook releases its archive writer. - func(e *delivery.Engine) delivery.WebhookEvictor { - return e - }, + // Wire *delivery.Engine as delivery.Archives so deleting + // or renaming a webhook or target reaches its archive + // files. + func(e *delivery.Engine) delivery.Archives { return e }, + // Wire *delivery.Engine as delivery.CircuitBreakers so + // the pages can show a target whose deliveries are + // paused. + func(e *delivery.Engine) delivery.CircuitBreakers { return e }, server.New, ), fx.Invoke( diff --git a/cmd/webhooker/main_test.go b/cmd/webhooker/main_test.go index 98ca68e..160cb1d 100644 --- a/cmd/webhooker/main_test.go +++ b/cmd/webhooker/main_test.go @@ -2,12 +2,19 @@ package main import ( "bytes" + "encoding/json" + "io" + "log/slog" + "net" + "os" + "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/server" @@ -30,6 +37,7 @@ const dockerStopGrace = 10 * time.Second // fx.New applies options before it executes invokes, so the timeout // is set whether or not the graph itself can be constructed here. func TestNewApp_StopTimeout(t *testing.T) { + configtest.ClearEnv(t) t.Setenv("DATA_DIR", t.TempDir()) got := newApp().StopTimeout() @@ -38,6 +46,100 @@ func TestNewApp_StopTimeout(t *testing.T) { require.Less(t, got, dockerStopGrace) } +// freePort returns a loopback TCP port that was free a moment ago, by +// taking one and releasing it. +func freePort(t *testing.T) int { + t.Helper() + + var listenCfg net.ListenConfig + + l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + addr, ok := l.Addr().(*net.TCPAddr) + require.True(t, ok, "listener is not TCP") + require.NoError(t, l.Close()) + + return addr.Port +} + +// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main +// runs, with DEBUG=true, and reads back what reached the service's +// logger. fx's own events must arrive there as structured records: +// the start at INFO, and at DEBUG the records of how the graph was +// built. +// +// fx holds its events back until its logger is built and then replays +// them all at once, so the earliest of them arriving shows the replay +// ran at DEBUG: that globals.New was provided, which fx records before +// anything is built, and the run of logger.New, which happens before +// the configuration sets the level. +func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) { + configtest.ClearEnv(t) + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("PORT", strconv.Itoa(freePort(t))) + t.Setenv("DEBUG", "true") + + // internal/logger writes to whatever os.Stdout is when it builds + // its handler. A file is not a terminal, so that handler is the + // JSON one the service uses in production. + out, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + + stdout := os.Stdout + os.Stdout = out + + t.Cleanup(func() { + os.Stdout = stdout + _ = out.Close() + }) + + app := newApp() + require.NoError(t, app.Start(t.Context())) + require.NoError(t, app.Stop(t.Context())) + + _, err = out.Seek(0, io.SeekStart) + require.NoError(t, err) + + written, err := io.ReadAll(out) + require.NoError(t, err) + + type record struct { + Level string `json:"level"` + Msg string `json:"msg"` + Name string `json:"name"` + Constructor string `json:"constructor"` + } + + var records []record + + for line := range strings.Lines(string(written)) { + var r record + + // The first-boot banner is plain text, not a record. + if json.Unmarshal([]byte(line), &r) == nil { + records = append(records, r) + } + } + + const pkg = "sneak.berlin/go/webhooker/internal/" + + info := slog.LevelInfo.String() + debug := slog.LevelDebug.String() + + assert.Contains(t, records, record{Level: info, Msg: "started"}) + assert.Contains(t, records, record{ + Level: debug, Msg: "provided", Constructor: pkg + "globals.New()", + }) + assert.Contains(t, records, record{ + Level: debug, Msg: "run", Name: pkg + "logger.New()", + }) + assert.Contains(t, records, record{Level: debug, Msg: "invoking"}) + assert.Contains(t, records, record{ + Level: debug, Msg: "initialized custom fxevent.Logger", + }) +} + // TestRunRefusesLockedDataDir pins what an operator's second start // does. The entry point must refuse before it builds the fx graph — // nothing may open a database in a DATA_DIR another process holds — @@ -150,22 +252,40 @@ const tailHeadroom = 2 * time.Second // can produce, since a shorter drain leaves the flush more room and // the worst case is not necessarily at either extreme. // -// Shrinking either budget, or unbounding the flush again, must fail -// here rather than silently recreating a hook that swallows the -// whole sequence. +// Nor does the hook start on a full budget: the ArchiveSweeper and +// RetentionReaper hooks run before it, and whatever they spent is +// gone. The outer sweep walks every amount they can spend. Once they +// have eaten into the headroom themselves, the hook must spend +// nothing of what is left. A drain that starts on the full budget +// must still get all of ShutdownTimeout, so a smaller stopTimeout +// cannot silently shorten every drain. +// +// Shrinking either budget, or unbounding the drain or the flush +// again, must fail here rather than silently recreating a hook that +// swallows the whole sequence. func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) { t.Parallel() require.Less(t, server.ShutdownTimeout, stopTimeout) + require.Equal( + t, server.ShutdownTimeout, server.DrainBudget(stopTimeout), + "a drain that starts on the full stop budget is cut short", + ) const step = 10 * time.Millisecond - for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step { - hook := drain + server.SentryFlushBudget(stopTimeout-drain) + for spent := time.Duration(0); spent <= stopTimeout; spent += step { + remaining := stopTimeout - spent + longest := max(server.DrainBudget(remaining), 0) - require.LessOrEqual( - t, hook+tailHeadroom, stopTimeout, - "a %s drain leaves the tail hooks short", drain, - ) + for drain := time.Duration(0); drain <= longest; drain += step { + hook := drain + server.SentryFlushBudget(remaining-drain) + + require.GreaterOrEqual( + t, remaining-hook, min(remaining, tailHeadroom), + "a %s drain after %s of earlier hooks leaves "+ + "the tail hooks short", drain, spent, + ) + } } } diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c085f35 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +// ESLint configuration for static/js/. script/lint and the image build run +// ESLint in the Dockerfile's js-lint stage, never on the host. +// +// The rules are the ones the JavaScript styleguide linked from +// REPO_POLICIES.md states that a linter can check: const for everything, +// let only for a variable that is reassigned, never var. +export default [ + // Alpine.js, extracted from 3p/ by make assets; not ours to lint. + { ignores: ["static/js/alpine.min.js"] }, + { + // The pages load static/js/app.js as a classic script, not a module. + languageOptions: { sourceType: "script" }, + rules: { + "no-var": "error", + "prefer-const": "error", + }, + }, +]; diff --git a/go.mod b/go.mod index 3fbd1d2..8542d5b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26.1 require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f + github.com/chromedp/chromedp v0.16.0 + github.com/dustin/go-humanize v1.0.1 github.com/getsentry/sentry-go v0.25.0 github.com/go-chi/chi v1.5.5 github.com/go-chi/cors v1.2.1 @@ -17,9 +20,8 @@ require ( github.com/prometheus/client_model v0.5.0 github.com/slok/go-http-metrics v0.11.0 github.com/stretchr/testify v1.11.1 - go.uber.org/fx v1.20.1 + go.uber.org/fx v1.24.0 golang.org/x/crypto v0.38.0 - gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 modernc.org/sqlite v1.28.0 @@ -28,14 +30,17 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect @@ -44,16 +49,16 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/dig v1.17.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/zap v1.23.0 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.26.0 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect diff --git a/go.sum b/go.sum index d2d615e..7eb057a 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,15 @@ github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk= +github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -23,6 +24,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5 github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -55,17 +64,20 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= @@ -82,28 +94,24 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= -go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU= -go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk= -go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg= -go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= -go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= @@ -111,8 +119,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= diff --git a/internal/ciscript/ci_mark_superseded_test.go b/internal/ciscript/ci_mark_superseded_test.go deleted file mode 100644 index 860092f..0000000 --- a/internal/ciscript/ci_mark_superseded_test.go +++ /dev/null @@ -1,387 +0,0 @@ -package ciscript_test - -import ( - "maps" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -const ( - // supersededDesc is the description script/ci-mark-superseded - // writes, and the one an earlier revision of it wrote alongside a - // `skipped` state. - supersededDesc = "Superseded by a newer commit; never tested" - - // liveContext is the commit-status context Gitea uses for this - // repository's runs, as seen in its API. The script derives it from - // the workflow and job names rather than hardcoding it; the - // derivation is checked against this value below. - liveContext = "check / check (push)" - - scriptPath = "../../script/ci-mark-superseded" - workflow = "../../.gitea/workflows/check.yml" - - // failure is the only state that neither folds into a combined - // `success` (as `skipped` does) nor blocks the commit forever (as - // `pending` does). - failure = "failure" -) - -// repo is a throwaway git history: parent is the commit a run would be -// cancelled on, head the commit that superseded it. -type repo struct { - dir string - head string - parent string -} - -// scriptEnv is the run identity the Gitea runner exports and the script -// builds its context string from. -type scriptEnv struct { - workflow string - job string - event string -} - -func defaultEnv() scriptEnv { - return scriptEnv{workflow: "check", job: "check", event: "push"} -} - -func cancelled() commitStatus { - return commitStatus{ - Context: liveContext, - Status: failure, - Description: "Has been cancelled", - } -} - -func running() commitStatus { - return commitStatus{ - Context: liveContext, - Status: "pending", - Description: "Has started running", - } -} - -func TestMarkSuperseded(t *testing.T) { - t.Parallel() - - cases := map[string]struct { - parent commitStatus - wantMark bool - }{ - "a cancelled run is marked": { - parent: cancelled(), - wantMark: true, - }, - "a laundered skipped status is marked": { - parent: commitStatus{ - Context: liveContext, - Status: "skipped", - Description: supersededDesc, - }, - wantMark: true, - }, - "a genuine failure is left alone": { - parent: commitStatus{ - Context: liveContext, - Status: failure, - Description: "Failing after 3m1s", - }, - wantMark: false, - }, - "a passing run is left alone": { - parent: commitStatus{ - Context: liveContext, - Status: "success", - Description: "Successful in 2m52s", - }, - wantMark: false, - }, - "another context is left alone": { - parent: commitStatus{ - Context: "other / other (push)", - Status: failure, - Description: "Has been cancelled", - }, - wantMark: false, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, tc.parent) - - out, err := runScript(t, history, api, defaultEnv()) - require.NoError(t, err, out) - - posted := fake.postedFor(history.parent) - if !tc.wantMark { - require.Empty(t, posted) - - return - } - - require.Equal(t, []postedStatus{{ - Context: liveContext, - // Not `skipped`: Gitea's combined status folds - // that into `success`, which is what made a - // never-tested commit read green. - State: failure, - Description: supersededDesc, - }}, posted) - }) - } -} - -// A second run must not rewrite what the first one wrote, or every -// later push would post a duplicate status. -func TestMarkSupersededIsIdempotent(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - for range 2 { - out, err := runScript(t, history, api, defaultEnv()) - require.NoError(t, err, out) - } - - require.Len(t, fake.postedFor(history.parent), 1) -} - -// Renaming the workflow or the job changes the context string Gitea -// uses. The script must say so instead of quietly matching nothing. -func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - env := defaultEnv() - env.job = "renamed" - - out, err := runScript(t, history, api, env) - require.Error(t, err) - require.Contains(t, out, "renamed") - require.Contains(t, out, liveContext) - require.Empty(t, fake.postedFor(history.parent)) -} - -// ANCESTOR_LIMIT is a documented knob. A value that is set but unusable -// must abort: handing it to git and discarding the exit status left the -// walk empty and the step green, marking nothing. -func TestMarkSupersededRejectsAnUnparseableAncestorLimit(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript( - t, history, api, defaultEnv(), "ANCESTOR_LIMIT=twenty", - ) - require.Error(t, err) - require.Contains(t, out, "ANCESTOR_LIMIT") - require.Contains(t, out, "twenty") - require.Empty(t, fake.postedFor(history.parent)) -} - -// A status read that fails is not the same as a commit with nothing to -// do. Losing curl's exit status through a pipe made the two identical -// and left a laundered commit laundered with no signal. -func TestMarkSupersededFailsOnAnUnreadableAncestorStatus(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - fake.failStatusRead(history.parent) - - out, err := runScript(t, history, api, defaultEnv()) - require.Error(t, err) - require.Contains(t, out, history.parent) - require.Contains(t, out, "cannot read commit statuses") - require.Empty(t, fake.postedFor(history.parent)) -} - -// A shallow clone cannot resolve the parent, so it is indistinguishable -// from a root commit to rev-parse and the walk would exit 0 having -// marked nothing. It must abort instead: dropping `fetch-depth: 0` from -// the checkout step is one edit, and a silent no-op there restores the -// false-green bug this script exists to prevent. -func TestMarkSupersededRejectsAShallowRepository(t *testing.T) { - t.Parallel() - requireTools(t) - - history := shallowClone(t, newRepo(t)) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript(t, history, api, defaultEnv()) - require.Error(t, err) - require.Contains(t, out, "shallow repository") - require.Empty(t, fake.postedFor(history.parent)) - require.Empty(t, fake.postedFor(history.head)) -} - -// shallowClone returns the same history as a depth-1 clone. The `file://` -// URL is required: git ignores --depth for a plain local path. -func shallowClone(t *testing.T, history repo) repo { - t.Helper() - - dir := t.TempDir() - - //nolint:gosec // fixed argv, arguments are test-local paths - cmd := exec.CommandContext(t.Context(), "git", "clone", "-q", - "--depth=1", "file://"+history.dir, dir) - - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) - - return repo{dir: dir, head: history.head, parent: history.parent} -} - -// The derived context must equal the one Gitea actually uses, which is -// built from the same workflow and job names. -func TestDerivedContextMatchesGitea(t *testing.T) { - t.Parallel() - requireTools(t) - - name, job := workflowIdentity(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript(t, history, api, scriptEnv{ - workflow: name, - job: job, - event: "push", - }) - require.NoError(t, err, out) - - posted := fake.postedFor(history.parent) - require.Len(t, posted, 1) - require.Equal(t, liveContext, posted[0].Context) -} - -// workflowIdentity reads the workflow name and its single job id out of -// the checked-in workflow file. -func workflowIdentity(t *testing.T) (string, string) { - t.Helper() - - raw, err := os.ReadFile(workflow) - require.NoError(t, err) - - var parsed struct { - Name string `yaml:"name"` - Jobs map[string]any `yaml:"jobs"` - } - - require.NoError(t, yaml.Unmarshal(raw, &parsed)) - - jobs := slices.Collect(maps.Keys(parsed.Jobs)) - require.Len(t, jobs, 1) - - return parsed.Name, jobs[0] -} - -func runScript( - t *testing.T, history repo, api string, env scriptEnv, - extra ...string, -) (string, error) { - t.Helper() - - script, err := filepath.Abs(scriptPath) - require.NoError(t, err) - - //nolint:gosec // fixed argv, repo-local script under test - cmd := exec.CommandContext(t.Context(), "sh", script) - cmd.Dir = history.dir - cmd.Env = append(os.Environ(), - "GITHUB_API_URL="+api, - "GITHUB_REPOSITORY=sneak/webhooker", - "GITHUB_SHA="+history.head, - "GITHUB_WORKFLOW="+env.workflow, - "GITHUB_JOB="+env.job, - "GITHUB_EVENT_NAME="+env.event, - "GITEA_TOKEN=test-token", - ) - cmd.Env = append(cmd.Env, extra...) - - out, err := cmd.CombinedOutput() - - return string(out), err -} - -func newRepo(t *testing.T) repo { - t.Helper() - - dir := t.TempDir() - - git := func(args ...string) string { - //nolint:gosec // fixed argv, arguments are test constants - cmd := exec.CommandContext(t.Context(), "git", args...) - cmd.Dir = dir - - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) - - return strings.TrimSpace(string(out)) - } - - commit := func(message string) string { - git( - "-c", "user.email=ci@example.invalid", - "-c", "user.name=ci", - "-c", "commit.gpgsign=false", - "commit", "-q", "--allow-empty", "-m", message, - ) - - return git("rev-parse", "HEAD") - } - - git("init", "-q", "-b", "main") - - parent := commit("parent") - head := commit("head") - - return repo{dir: dir, head: head, parent: parent} -} - -func requireTools(t *testing.T) { - t.Helper() - - for _, tool := range []string{"sh", "git", "curl", "jq"} { - _, err := exec.LookPath(tool) - if err != nil { - t.Skipf("%s is not installed: %v", tool, err) - } - } -} diff --git a/internal/ciscript/doc.go b/internal/ciscript/doc.go deleted file mode 100644 index 3867bfd..0000000 --- a/internal/ciscript/doc.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package ciscript holds the tests for the repository's CI shell -// scripts in script/. It carries no runtime code: the scripts run on -// the CI runner, not inside the binary, but their behaviour still has -// to be verified by the test suite. -// -// The scripts under test are outside the Go build graph, so `go test`'s -// result cache serves a stale PASS when only a script changed: run the -// container build, or GOFLAGS=-count=1, to trust a result here after -// editing script/. -package ciscript diff --git a/internal/ciscript/fakegitea_test.go b/internal/ciscript/fakegitea_test.go deleted file mode 100644 index a3a38e1..0000000 --- a/internal/ciscript/fakegitea_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package ciscript_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" -) - -// commitStatus is the part of an entry in Gitea's combined-status -// response that script/ci-mark-superseded reads. -type commitStatus struct { - Context string `json:"context"` - Status string `json:"status"` - Description string `json:"description"` -} - -// postedStatus is the part of a create-status request body the script -// writes. -type postedStatus struct { - Context string `json:"context"` - State string `json:"state"` - Description string `json:"description"` -} - -// fakeGitea serves the two endpoints the script talks to. Like Gitea, -// the newest status for a context replaces the previous one, so a -// second run of the script sees what the first one wrote. -type fakeGitea struct { - mu sync.Mutex - statuses map[string][]commitStatus - posted map[string][]postedStatus - // failRead is a commit whose combined-status read answers HTTP - // 500, standing in for a status API that is down. - failRead string -} - -// newFakeGitea returns the fake and the base URL to hand the script as -// GITHUB_API_URL. -func newFakeGitea(t *testing.T) (*fakeGitea, string) { - t.Helper() - - fake := &fakeGitea{ - mu: sync.Mutex{}, - statuses: map[string][]commitStatus{}, - posted: map[string][]postedStatus{}, - failRead: "", - } - - srv := httptest.NewServer(fake.routes()) - t.Cleanup(srv.Close) - - return fake, srv.URL -} - -func (f *fakeGitea) routes() http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc( - "GET /repos/{owner}/{repo}/commits/{sha}/status", - f.handleCombined, - ) - mux.HandleFunc( - "POST /repos/{owner}/{repo}/statuses/{sha}", - f.handleCreate, - ) - - return mux -} - -func (f *fakeGitea) handleCombined( - w http.ResponseWriter, r *http.Request, -) { - f.mu.Lock() - defer f.mu.Unlock() - - sha := r.PathValue("sha") - if f.failRead != "" && f.failRead == sha { - http.Error(w, "boom", http.StatusInternalServerError) - - return - } - - body := struct { - Statuses []commitStatus `json:"statuses"` - }{Statuses: f.statuses[sha]} - - payload, err := json.Marshal(body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - w.Header().Set("Content-Type", "application/json") - - _, _ = w.Write(payload) -} - -func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) { - var got postedStatus - - err := json.NewDecoder(r.Body).Decode(&got) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - - return - } - - sha := r.PathValue("sha") - - f.mu.Lock() - defer f.mu.Unlock() - - f.posted[sha] = append(f.posted[sha], got) - f.replaceLocked(sha, commitStatus{ - Context: got.Context, - Status: got.State, - Description: got.Description, - }) - - w.WriteHeader(http.StatusCreated) -} - -// failStatusRead makes the combined-status read for one commit answer -// HTTP 500. -func (f *fakeGitea) failStatusRead(sha string) { - f.mu.Lock() - defer f.mu.Unlock() - - f.failRead = sha -} - -// setStatus gives a commit its latest status for a context. -func (f *fakeGitea) setStatus(sha string, status commitStatus) { - f.mu.Lock() - defer f.mu.Unlock() - - f.replaceLocked(sha, status) -} - -// postedFor returns the statuses the script created for a commit. -func (f *fakeGitea) postedFor(sha string) []postedStatus { - f.mu.Lock() - defer f.mu.Unlock() - - return append([]postedStatus(nil), f.posted[sha]...) -} - -// replaceLocked requires f.mu. -func (f *fakeGitea) replaceLocked(sha string, status commitStatus) { - for i, existing := range f.statuses[sha] { - if existing.Context == status.Context { - f.statuses[sha][i] = status - - return - } - } - - f.statuses[sha] = append(f.statuses[sha], status) -} diff --git a/internal/config/clearenv_test.go b/internal/config/clearenv_test.go new file mode 100644 index 0000000..17f8757 --- /dev/null +++ b/internal/config/clearenv_test.go @@ -0,0 +1,37 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" +) + +// TestClearEnv_RemovesAddedVariables pins that a variable set +// after the clear other than through t.Setenv, as a test's .env file +// sets one, is gone once the test ends, so it cannot reach the tests +// that run after it. +// +//nolint:paralleltest // ClearEnv uses t.Setenv. +func TestClearEnv_RemovesAddedVariables(t *testing.T) { + // The outer clear keeps a value of the key exported in the shell + // from making it a variable the inner clear has to put back. + configtest.ClearEnv(t) + + t.Run("loads a .env file after the clear", func(t *testing.T) { + configtest.ClearEnv(t) + + path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n") + require.NoError(t, config.LoadDotEnvFileForTest(path)) + require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey)) + }) + + _, present := os.LookupEnv(dotEnvKey) + assert.False( + t, present, + "a variable set after the clear must not outlive the test", + ) +} diff --git a/internal/config/config.go b/internal/config/config.go index 1b9a4e7..7ba8150 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,10 @@ const ( // authenticated activity before it expires. defaultSessionIdleTimeout = 24 * time.Hour + // defaultHSTSMaxAge is the max-age of the Strict-Transport-Security + // header when HSTS_MAX_AGE is unset: ten years. + defaultHSTSMaxAge = 87600 * time.Hour + // defaultReceiverRateLimit is the default number of requests // per minute each client IP may send to a single webhook // receiver entrypoint. Generous for legitimate webhook @@ -75,8 +79,12 @@ const ( // internet-exposed endpoint. defaultReceiverRateLimit = 120 - // maxPort is the highest valid TCP port number. The lower - // bound (at least 1) is enforced by envPositiveInt. + // defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the + // RFC 1918 private ranges, which a reverse proxy reaching the + // process over a Docker network or a private LAN connects from. + defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + + // maxPort is the highest valid TCP port number. maxPort = 65535 // mappedV4Offset is the number of leading bits an IPv4-mapped @@ -100,7 +108,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment") var ErrNonPositiveValue = errors.New("value must be positive") // ErrInvalidPort is returned when an environment variable holding a -// TCP port number is set above the valid port range. +// TCP port number is set to a number outside 1 to 65535. var ErrInvalidPort = errors.New("invalid port") // ErrInvalidCIDR is returned when an environment variable holding a @@ -116,6 +124,10 @@ var ErrInvalidBindAddress = errors.New("invalid bind address") // the Sentry SDK cannot parse as a DSN. var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN") +// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a +// negative duration or one that is not a whole number of seconds. +var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age") + // ErrDotEnvUnreadable is returned when the optional .env file exists // but cannot be read or parsed. A file that is not there is not an // error; a file that is there and broken is. @@ -144,7 +156,6 @@ type ConfigParams struct { type Config struct { DataDir string Debug bool - MaintenanceMode bool Environment string MetricsPassword string MetricsUsername string @@ -166,19 +177,24 @@ type Config struct { // which a session expires. Non-positive disables idle expiry. SessionIdleTimeout time.Duration + // HSTSMaxAge is the max-age of the Strict-Transport-Security + // header. Zero means the header is not sent at all. + HSTSMaxAge time.Duration + // ReceiverRateLimit is the number of requests per minute each // client IP may send to a single webhook receiver entrypoint. ReceiverRateLimit int // TrustedProxies is the set of networks whose members are // allowed to speak for the client with X-Forwarded-For, the - // only forwarded header read. It is empty unless - // TRUSTED_PROXIES is set, and empty means no peer is - // trusted: forwarded headers are then ignored entirely and - // clients are identified by the connection's own address. - // Members can choose their own rate-limit key, so this must - // name proxy hosts only, never a block that also covers - // clients. + // only forwarded header read. Unless TRUSTED_PROXIES is set it + // is the RFC 1918 private ranges (defaultTrustedProxies); a set + // value replaces them. If any client can reach the process, or + // the proxy in front of it, from an RFC 1918 source address + // (directly, or through anything that can rewrite source + // addresses, such as NAT or a published container port), it + // must be set to the proxy's address alone, or every rate limit + // can be bypassed by those clients. TrustedProxies []netip.Prefix // AllowedEgressCIDRs is the set of networks a delivery target @@ -190,12 +206,13 @@ type Config struct { // otherwise refuse. The guard itself is always on: there is no // setting that disables SSRF protection, and delivery's // alwaysBlockedNetworks stays blocked no matter what is listed - // here. That set is link-local plus the cloud metadata - // endpoints outside it that disclose credentials or user data - // at a provider-fixed, non-public address; it is not - // exhaustive of every cloud's metadata address. See - // alwaysBlockedNetworks for the authoritative list and the - // criterion it is built from. + // here. That set is link-local, the unspecified addresses + // 0.0.0.0 and ::, and the cloud metadata endpoints outside + // link-local that disclose credentials or user data at a + // provider-fixed, non-public address; it is not exhaustive of + // every cloud's metadata address. See + // alwaysBlockedNetworks for the authoritative list and why + // each entry is on it. AllowedEgressCIDRs []netip.Prefix params *ConfigParams @@ -357,17 +374,27 @@ func envPositiveInt( // envPort returns the value of the named environment variable parsed // as a TCP port number. Returns defaultValue if not set. A set value // that is unparseable, below 1, or above maxPort is a hard error -// naming the key and the bad value. +// naming the key and the bad value; every out-of-range value wraps +// ErrInvalidPort, including one too large or too small for an int. func envPort(key string, defaultValue int) (int, error) { - port, err := envPositiveInt(key, defaultValue) - if err != nil { - return 0, err + v := os.Getenv(key) + if v == "" { + return defaultValue, nil } - if port > maxPort { + // strconv.ErrRange means a number too large or too small for an + // int, which is outside the port range as well. + port, err := strconv.Atoi(v) + if err != nil && !errors.Is(err, strconv.ErrRange) { return 0, fmt.Errorf( - "%w: %s must be at most %d, got %d", - ErrInvalidPort, key, maxPort, port, + "invalid integer for %s: %q: %w", key, v, err, + ) + } + + if err != nil || port < 1 || port > maxPort { + return 0, fmt.Errorf( + "%w: %s must be from 1 to %d, got %q", + ErrInvalidPort, key, maxPort, v, ) } @@ -460,14 +487,15 @@ func parseCIDR(entry string) (netip.Prefix, error) { // envPrefixList returns the value of the named environment variable // parsed as a comma-separated list of CIDR blocks (bare addresses -// allowed). An unset, empty, or blank value yields an empty list. A -// set value containing an unparseable entry is a hard error naming -// the key and the bad entry, so startup fails loudly rather than -// silently running with a list the operator did not intend. -func envPrefixList(key string) ([]netip.Prefix, error) { +// allowed). An unset, empty, or blank value is read as defaultValue +// instead. A set value containing an unparseable entry is a hard +// error naming the key and the bad entry, so startup fails loudly +// rather than silently running with a list the operator did not +// intend. +func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) { v := strings.TrimSpace(os.Getenv(key)) if v == "" { - return nil, nil + v = defaultValue } var prefixes []netip.Prefix @@ -608,6 +636,27 @@ func resolveEnvironment() (string, error) { return environment, nil } +// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the +// Strict-Transport-Security header off. A fraction of a second is an +// error rather than rounded, because the header carries whole seconds +// and would otherwise say something other than what was set. +func resolveHSTSMaxAge() (time.Duration, error) { + maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge) + if err != nil { + return 0, err + } + + if maxAge < 0 || maxAge%time.Second != 0 { + return 0, fmt.Errorf( + "%w: HSTS_MAX_AGE must be zero or a positive whole "+ + "number of seconds, got %s", + ErrInvalidHSTSMaxAge, maxAge, + ) + } + + return maxAge, nil +} + // resolveListener reads the two variables that describe the HTTP // listener: which port it claims and which address it claims it on. // They are read together because neither is meaningful alone, and @@ -650,11 +699,6 @@ func loadFromEnv() (*Config, error) { return nil, err } - maintenanceMode, err := envBool("MAINTENANCE_MODE", false) - if err != nil { - return nil, err - } - retentionSweepInterval, err := envPositiveDuration( "RETENTION_SWEEP_INTERVAL", defaultRetentionSweepInterval, @@ -673,6 +717,11 @@ func loadFromEnv() (*Config, error) { return nil, err } + hstsMaxAge, err := resolveHSTSMaxAge() + if err != nil { + return nil, err + } + receiverRateLimit, err := envPositiveInt( "RECEIVER_RATE_LIMIT", defaultReceiverRateLimit, @@ -681,12 +730,12 @@ func loadFromEnv() (*Config, error) { return nil, err } - trustedProxies, err := envPrefixList("TRUSTED_PROXIES") + trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies) if err != nil { return nil, err } - allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS") + allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "") if err != nil { return nil, err } @@ -704,7 +753,6 @@ func loadFromEnv() (*Config, error) { return &Config{ DataDir: DataDir(), Debug: debug, - MaintenanceMode: maintenanceMode, Environment: environment, MetricsUsername: metricsUsername, MetricsPassword: metricsPassword, @@ -713,6 +761,7 @@ func loadFromEnv() (*Config, error) { SentryDSN: sentryDSN, RetentionSweepInterval: retentionSweepInterval, SessionIdleTimeout: sessionIdleTimeout, + HSTSMaxAge: hstsMaxAge, ReceiverRateLimit: receiverRateLimit, TrustedProxies: trustedProxies, AllowedEgressCIDRs: allowedEgressCIDRs, @@ -760,50 +809,6 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) { ) } -// warnSharedRateLimitBucket logs a startup warning whenever -// TRUSTED_PROXIES is empty, in any environment. -// -// With no trusted proxies every rate limiter keys on the connecting -// peer's address. Whether that is harmless or dangerous depends on -// what is in front of the process, which this code cannot observe: -// with nothing in front, the peer is the client and the limits are -// per-client as intended; behind a reverse proxy the peer is the proxy -// for every request, so all clients share one bucket per limiter. -// -// The login endpoint no longer spends budget on arrival — it verifies -// credentials first and charges only failures — so a shared bucket -// cannot deny the operator a correct password. What it does collapse -// is the failure counting: one client's wrong passwords throttle -// everyone else's wrong passwords, and the receiver's limits become -// service-wide ceilings. -// -// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT: -// behind a proxy every client shares one bucket in dev and prod alike. -// -// The default of trusting nobody is deliberate — trusting forwarded -// headers from arbitrary peers lets any client choose its own bucket — -// so this warns rather than failing startup or changing the key. -func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) { - if len(c.TrustedProxies) > 0 { - return - } - - log.Warn( - "TRUSTED_PROXIES is empty: every rate limit keys on the "+ - "connecting peer's address. With nothing proxying to "+ - "this process that is the client itself and the limits "+ - "are per-client as intended. Behind a reverse proxy the "+ - "peer is the proxy on every request, so all clients "+ - "share one bucket per limit: the receiver limits become "+ - "service-wide ceilings, and one client's failed logins "+ - "throttle every other client's failed logins — a "+ - "correct password still gets in. If anything proxies to "+ - "this process, set TRUSTED_PROXIES to its address.", - "environment", c.Environment, - "trustedProxies", len(c.TrustedProxies), - ) -} - // New creates a Config by reading environment variables. // //nolint:revive // lc parameter is required by fx even if unused. @@ -835,13 +840,13 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { // host can reach the admin UI. "bindAddress", s.BindAddress, "debug", s.Debug, - "maintenanceMode", s.MaintenanceMode, "dataDir", s.DataDir, "retentionSweepInterval", s.RetentionSweepInterval.String(), // Logged because a perfectly valid non-positive value here // disables idle expiry entirely, and that is worth showing // back to the operator. "sessionIdleTimeout", s.SessionIdleTimeout.String(), + "hstsMaxAge", s.HSTSMaxAge.String(), "receiverRateLimit", s.ReceiverRateLimit, "trustedProxies", len(s.TrustedProxies), "allowedEgressCIDRs", len(s.AllowedEgressCIDRs), @@ -849,7 +854,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { "hasMetricsAuth", s.MetricsAuthEnabled(), ) - s.warnSharedRateLimitBucket(log) s.warnEgressAllowlist(log) return s, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a7cc76e..be65c71 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,7 +3,6 @@ package config_test import ( "bytes" "log/slog" - "os" "testing" "time" @@ -12,6 +11,7 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -71,14 +71,12 @@ func TestEnvironmentConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if tt.envValue != "" { t.Setenv( "WEBHOOKER_ENVIRONMENT", tt.envValue, ) - } else { - require.NoError(t, os.Unsetenv( - "WEBHOOKER_ENVIRONMENT", - )) } for k, v := range tt.envVars { @@ -124,6 +122,11 @@ func testEnvironmentConfigSuccess( app := fxtest.New( t, + // fx's own log is discarded, not sent to t.Logf: a hook still + // running after a start or stop timeout would write there after + // the test has returned. The same holds for every fxtest.New + // below. + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -194,14 +197,11 @@ func TestRetentionSweepInterval(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value) - } else { - require.NoError(t, os.Unsetenv( - "RETENTION_SWEEP_INTERVAL", - )) } if tt.expectError { @@ -272,6 +272,7 @@ func testRetentionSweepIntervalSuccess( app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -335,14 +336,11 @@ func TestSessionIdleTimeout(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("SESSION_IDLE_TIMEOUT", tt.value) - } else { - require.NoError(t, os.Unsetenv( - "SESSION_IDLE_TIMEOUT", - )) } if tt.expectError { @@ -364,6 +362,7 @@ func testSessionIdleTimeoutSuccess( app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -380,6 +379,83 @@ func testSessionIdleTimeoutSuccess( assert.Equal(t, expected, cfg.SessionIdleTimeout) } +func TestHSTSMaxAge(t *testing.T) { + tests := []struct { + name string + set bool + value string + expectError bool + // sentinel, when set, must be wrapped by the startup + // error; every error case must additionally name the + // variable in its message. + sentinel error + expected time.Duration + }{ + { + name: caseUnsetUsesDefault, + set: false, + expected: 87600 * time.Hour, + }, + { + name: caseValidValueParsed, + set: true, + value: "720h", + expected: 720 * time.Hour, + }, + { + // Zero turns the header off; it is not an error. + name: "zero is accepted", + set: true, + value: "0", + expected: 0, + }, + { + name: caseUnparseableFails, + set: true, + value: "10 years", + expectError: true, + }, + { + // A whole number of seconds, so only the sign is wrong. + name: "negative whole seconds fail startup", + set: true, + value: "-1s", + expectError: true, + sentinel: config.ErrInvalidHSTSMaxAge, + }, + { + name: "fraction of a second fails startup", + set: true, + value: "1500ms", + expectError: true, + sentinel: config.ErrInvalidHSTSMaxAge, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Cannot use t.Parallel() here because t.Setenv + // is incompatible with parallel subtests. + configtest.ClearEnv(t) + t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") + + if tt.set { + t.Setenv("HSTS_MAX_AGE", tt.value) + } + + if tt.expectError { + expectStartupErrorFor(t, "HSTS_MAX_AGE", tt.sentinel) + + return + } + + cfg, err := buildConfig(t) + require.NoError(t, err) + assert.Equal(t, tt.expected, cfg.HSTSMaxAge) + }) + } +} + func TestDefaultDataDir(t *testing.T) { for _, env := range []string{"", "dev", "prod"} { name := env @@ -390,20 +466,17 @@ func TestDefaultDataDir(t *testing.T) { t.Run("env="+name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if env != "" { t.Setenv("WEBHOOKER_ENVIRONMENT", env) - } else { - require.NoError(t, os.Unsetenv( - "WEBHOOKER_ENVIRONMENT", - )) } - require.NoError(t, os.Unsetenv("DATA_DIR")) - var cfg *config.Config app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -438,9 +511,9 @@ func TestDataDirHelper(t *testing.T) { t.Run(name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - if set == "" { - require.NoError(t, os.Unsetenv("DATA_DIR")) - } else { + configtest.ClearEnv(t) + + if set != "" { t.Setenv("DATA_DIR", set) } @@ -503,14 +576,11 @@ func TestReceiverRateLimit(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("RECEIVER_RATE_LIMIT", tt.value) - } else { - require.NoError(t, os.Unsetenv( - "RECEIVER_RATE_LIMIT", - )) } if tt.expectError { @@ -534,6 +604,7 @@ func testReceiverRateLimitSuccess( app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -551,6 +622,11 @@ func testReceiverRateLimitSuccess( } func TestTrustedProxies(t *testing.T) { + // Unset, the RFC 1918 private ranges are trusted, so a reverse + // proxy on a Docker network or a private LAN is covered without + // configuration. + defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"} + tests := []struct { name string set bool @@ -559,18 +635,21 @@ func TestTrustedProxies(t *testing.T) { expected []string }{ { - // The default must be "trust nobody": an empty list - // means forwarded headers are ignored, never that - // every peer may speak for the client. name: caseUnsetUsesDefault, set: false, - expected: []string{}, + expected: defaultProxies, }, { - name: "blank value trusts nothing", + name: "blank value uses default", set: true, value: " ", - expected: []string{}, + expected: defaultProxies, + }, + { + name: "set value replaces the default entirely", + set: true, + value: "203.0.113.7", + expected: []string{"203.0.113.7/32"}, }, { name: caseValidValueParsed, @@ -613,12 +692,11 @@ func TestTrustedProxies(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("TRUSTED_PROXIES", tt.value) - } else { - require.NoError(t, os.Unsetenv("TRUSTED_PROXIES")) } if tt.expectError { @@ -642,6 +720,7 @@ func testTrustedProxiesSuccess( app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -724,14 +803,11 @@ func TestAllowedEgressCIDRs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value) - } else { - require.NoError( - t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"), - ) } if tt.expectError { @@ -755,6 +831,7 @@ func testAllowedEgressCIDRsSuccess( app := fxtest.New( t, + fx.NopLogger, fx.Provide( globals.New, logger.New, @@ -798,13 +875,10 @@ func TestEgressAllowlistWarning(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) - if tt.allowed == "" { - require.NoError( - t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"), - ) - } else { + if tt.allowed != "" { t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed) } @@ -845,101 +919,6 @@ func TestEgressAllowlistWarning(t *testing.T) { } } -// TestSharedRateLimitBucketWarning covers the startup warning that -// tells an operator a deployment behind a reverse proxy shares one -// rate-limit bucket between every client, which turns the receiver -// limits into service-wide ceilings and collapses login failure -// counting. It must fire whenever TRUSTED_PROXIES is empty, in any -// environment, because behind a proxy every client shares one bucket -// in dev and prod alike. It stays quiet once proxies are named. -func TestSharedRateLimitBucketWarning(t *testing.T) { - tests := []struct { - name string - environment string - trustedProxies string - expectWarning bool - }{ - { - name: "prod without trusted proxies warns", - environment: config.EnvironmentProd, - expectWarning: true, - }, - { - name: "prod with trusted proxies is quiet", - environment: config.EnvironmentProd, - trustedProxies: cidrPrivateV4, - expectWarning: false, - }, - { - name: "dev without trusted proxies warns", - environment: config.EnvironmentDev, - expectWarning: true, - }, - { - name: "dev with trusted proxies is quiet", - environment: config.EnvironmentDev, - trustedProxies: cidrPrivateV4, - expectWarning: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Cannot use t.Parallel() here because t.Setenv - // is incompatible with parallel subtests. - t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment) - - if tt.trustedProxies == "" { - require.NoError( - t, os.Unsetenv("TRUSTED_PROXIES"), - ) - } else { - t.Setenv("TRUSTED_PROXIES", tt.trustedProxies) - } - - var buf bytes.Buffer - - log := slog.New(slog.NewJSONHandler( - &buf, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }, - )) - - require.NoError( - t, - config.WarnSharedRateLimitBucketForTest(log), - ) - - if !tt.expectWarning { - assert.Empty(t, buf.String()) - - return - } - - logged := buf.String() - - assert.Contains(t, logged, `"level":"WARN"`) - assert.Contains(t, logged, "TRUSTED_PROXIES") - assert.Contains(t, logged, "share one bucket") - assert.Contains( - t, logged, "throttle every other client's failed logins", - ) - // The warning must not claim a lockout the login - // endpoint no longer permits: credentials are verified - // before any budget is spent. - assert.Contains( - t, logged, "a correct password still gets in", - ) - // The text must stay accurate for a developer with - // nothing in front of the process, where an empty - // list costs nothing. - assert.Contains( - t, logged, "nothing proxying to this process", - ) - }) - } -} - // metricsEnv describes what one subtest below puts in the // environment for a single METRICS_ variable. A variable that is // set to the empty string and one that is not set at all are @@ -1032,20 +1011,14 @@ func TestMetricsAuthConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if tt.username.set { t.Setenv("METRICS_USERNAME", tt.username.value) - } else { - require.NoError( - t, os.Unsetenv("METRICS_USERNAME"), - ) } if tt.password.set { t.Setenv("METRICS_PASSWORD", tt.password.value) - } else { - require.NoError( - t, os.Unsetenv("METRICS_PASSWORD"), - ) } if tt.expectError { @@ -1093,6 +1066,7 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) { app := fxtest.New( t, + fx.NopLogger, fx.Provide(globals.New, logger.New, config.New), fx.Populate(&cfg), ) diff --git a/internal/config/configtest/configtest.go b/internal/config/configtest/configtest.go new file mode 100644 index 0000000..abaefac --- /dev/null +++ b/internal/config/configtest/configtest.go @@ -0,0 +1,52 @@ +// Package configtest holds test support for code that reads the +// process environment. +package configtest + +import ( + "os" + "strings" + "testing" +) + +// ClearEnv unsets every variable in the process environment +// for the rest of the test, so a test sees only the variables it sets +// itself, not whatever the developer's shell exports. When the test +// ends it leaves the environment exactly as it found it: each variable +// it unset is put back, and any variable added since is removed. +func ClearEnv(t *testing.T) { + t.Helper() + + present := make(map[string]bool) + + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + present[key] = true + + // t.Setenv registers the restore; the Unsetenv after it is + // what makes the key absent, since a key set to the empty + // string is still present, and godotenv will not overwrite a + // present key. + t.Setenv(key, "") + + err := os.Unsetenv(key) + if err != nil { + t.Fatalf("unsetting %s: %v", key, err) + } + } + + // A variable the test adds other than through t.Setenv, as loading + // a .env file does, has no restore of its own. + t.Cleanup(func() { + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if present[key] { + continue + } + + err := os.Unsetenv(key) + if err != nil { + t.Errorf("unsetting %s: %v", key, err) + } + } + }) +} diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index 952bb32..9a9f073 100644 --- a/internal/config/dotenv_test.go +++ b/internal/config/dotenv_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" ) // dotEnvKey is a throwaway variable name the .env tests write and @@ -22,17 +23,6 @@ const malformedDotEnv = "PORT 19615\n" + "this is not = valid ! syntax\n" + "\"unclosed\n" -// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of -// the test and restores it afterwards. t.Setenv registers the restore; -// the Unsetenv that follows is what the test actually needs, because a -// variable set to the empty string is still present in os.Environ and -// godotenv would refuse to overwrite it. -func unsetDotEnvKey(t *testing.T) { - t.Helper() - t.Setenv(dotEnvKey, "placeholder") - require.NoError(t, os.Unsetenv(dotEnvKey)) -} - // writeDotEnv writes contents to a .env file in a fresh temporary // directory and returns its path. func writeDotEnv(t *testing.T, contents string) string { @@ -50,9 +40,9 @@ func writeDotEnv(t *testing.T, contents string) string { // normally rather than be refused for a file it was never meant to // have. // -//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { - unsetDotEnvKey(t) + configtest.ClearEnv(t) absent := filepath.Join(t.TempDir(), config.DotEnvPath) require.NoError(t, config.LoadDotEnvFileForTest(absent)) @@ -65,9 +55,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { // reaches the environment, which is the whole reason the file is read // at all. // -//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_AppliesValues(t *testing.T) { - unsetDotEnvKey(t) + configtest.ClearEnv(t) path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n") @@ -93,9 +83,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) { // reverts to its default; the process used to start that way with no // log line naming the file at all. // -//nolint:paralleltest // unsetDotEnvKey uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { - unsetDotEnvKey(t) + configtest.ClearEnv(t) path := writeDotEnv( t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", @@ -143,7 +133,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) { // //nolint:paralleltest // t.Chdir moves the whole process. func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) { - unsetDotEnvKey(t) + configtest.ClearEnv(t) dir := t.TempDir() require.NoError(t, os.WriteFile( diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 29985c6..067d677 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -1,13 +1,13 @@ package config_test import ( - "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -18,10 +18,9 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE" // Real configuration variables exercised by the config.New tests. const ( - envKeyPort = "PORT" - envKeyDebug = "DEBUG" - envKeyMaintenanceMode = "MAINTENANCE_MODE" - envKeyBindAddress = "BIND_ADDRESS" + envKeyPort = "PORT" + envKeyDebug = "DEBUG" + envKeyBindAddress = "BIND_ADDRESS" ) // Sample BIND_ADDRESS values used by the tables below. @@ -122,10 +121,10 @@ func TestEnvBool(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if tt.set { t.Setenv(testEnvKey, tt.value) - } else { - require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvBoolForTest( @@ -146,17 +145,62 @@ func TestEnvBool(t *testing.T) { } } +// envIntCase is one row of the envPositiveInt and envPort tables. +type envIntCase struct { + name string + set bool + value string + expectError bool + errIs error + expected int +} + +// runEnvIntCases runs each row through parse, which is +// envPositiveInt or envPort, with testEnvKey set to the row's value +// or left unset. +func runEnvIntCases( + t *testing.T, + parse func(key string, defaultValue int) (int, error), + defaultValue int, + tests []envIntCase, +) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Cannot use t.Parallel() here because t.Setenv + // is incompatible with parallel subtests. + configtest.ClearEnv(t) + + if tt.set { + t.Setenv(testEnvKey, tt.value) + } + + got, err := parse(testEnvKey, defaultValue) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), testEnvKey) + assert.Contains(t, err.Error(), tt.value) + + if tt.errIs != nil { + require.ErrorIs(t, err, tt.errIs) + } + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +//nolint:paralleltest // runEnvIntCases uses t.Setenv. func TestEnvPositiveInt(t *testing.T) { const defaultValue = 7 - tests := []struct { - name string - set bool - value string - expectError bool - errIs error - expected int - }{ + runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{ { name: "unset returns the default integer", expected: defaultValue, @@ -193,51 +237,14 @@ func TestEnvPositiveInt(t *testing.T) { expectError: true, errIs: config.ErrNonPositiveValue, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Cannot use t.Parallel() here because t.Setenv - // is incompatible with parallel subtests. - if tt.set { - t.Setenv(testEnvKey, tt.value) - } else { - require.NoError(t, os.Unsetenv(testEnvKey)) - } - - got, err := config.EnvPositiveIntForTest( - testEnvKey, defaultValue, - ) - - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), testEnvKey) - assert.Contains(t, err.Error(), tt.value) - - if tt.errIs != nil { - require.ErrorIs(t, err, tt.errIs) - } - - return - } - - require.NoError(t, err) - assert.Equal(t, tt.expected, got) - }) - } + }) } +//nolint:paralleltest // runEnvIntCases uses t.Setenv. func TestEnvPort(t *testing.T) { const defaultValue = 8080 - tests := []struct { - name string - set bool - value string - expectError bool - errIs error - expected int - }{ + runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{ { name: "unset returns the default port", expected: defaultValue, @@ -265,7 +272,14 @@ func TestEnvPort(t *testing.T) { set: true, value: "0", expectError: true, - errIs: config.ErrNonPositiveValue, + errIs: config.ErrInvalidPort, + }, + { + name: "negative is rejected", + set: true, + value: "-1", + expectError: true, + errIs: config.ErrInvalidPort, }, { name: "above the port range is rejected", @@ -274,37 +288,14 @@ func TestEnvPort(t *testing.T) { expectError: true, errIs: config.ErrInvalidPort, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Cannot use t.Parallel() here because t.Setenv - // is incompatible with parallel subtests. - if tt.set { - t.Setenv(testEnvKey, tt.value) - } else { - require.NoError(t, os.Unsetenv(testEnvKey)) - } - - got, err := config.EnvPortForTest( - testEnvKey, defaultValue, - ) - - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), testEnvKey) - - if tt.errIs != nil { - require.ErrorIs(t, err, tt.errIs) - } - - return - } - - require.NoError(t, err) - assert.Equal(t, tt.expected, got) - }) - } + { + name: "too large for an int is rejected", + set: true, + value: "99999999999999999999", + expectError: true, + errIs: config.ErrInvalidPort, + }, + }) } // TestEnvBindAddress covers BIND_ADDRESS parsing. @@ -320,10 +311,10 @@ func TestEnvBindAddress(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if tt.set { t.Setenv(testEnvKey, tt.value) - } else { - require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvBindAddressForTest( @@ -486,6 +477,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv(tt.key, tt.value) @@ -604,12 +596,6 @@ func flagEnvValueCases() []badEnvValueCase { value: "ture", expectError: true, }, - { - name: "unparseable MAINTENANCE_MODE aborts startup", - key: envKeyMaintenanceMode, - value: "sometimes", - expectError: true, - }, } } @@ -653,22 +639,15 @@ func sentryEnvValueCases() []badEnvValueCase { // break the legitimate unset case: absent variables still get their // documented defaults. func TestNewUsesDefaultsWhenUnset(t *testing.T) { + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") - for _, key := range []string{ - envKeyPort, envKeyDebug, envKeyMaintenanceMode, - envKeyBindAddress, envKeySentryDSN, - } { - require.NoError(t, os.Unsetenv(key)) - } - cfg, err := buildConfig(t) require.NoError(t, err) require.NotNil(t, cfg) assert.Equal(t, 8080, cfg.Port) assert.False(t, cfg.Debug) - assert.False(t, cfg.MaintenanceMode) // Loopback, not the wildcard: the default must not publish the // cleartext admin UI and the unauthenticated receiver on every diff --git a/internal/config/export_test.go b/internal/config/export_test.go index d4d3be8..5b3ee92 100644 --- a/internal/config/export_test.go +++ b/internal/config/export_test.go @@ -6,21 +6,6 @@ import "log/slog" // the external config_test package so each helper can be covered by // its own table-driven test without weakening the package API. -// WarnSharedRateLimitBucketForTest loads a Config from the current -// environment and emits its startup warnings to log. The real logger -// writes to stdout, so this lets the warning's firing condition be -// asserted against a handler the test controls. -func WarnSharedRateLimitBucketForTest(log *slog.Logger) error { - c, err := loadFromEnv() - if err != nil { - return err - } - - c.warnSharedRateLimitBucket(log) - - return nil -} - // WarnEgressAllowlistForTest loads a Config from the current // environment and emits its egress-allowlist startup warning to // log, so a test can assert both that the warning fires only when diff --git a/internal/config/sentry_test.go b/internal/config/sentry_test.go index 0b475bf..d713f62 100644 --- a/internal/config/sentry_test.go +++ b/internal/config/sentry_test.go @@ -1,12 +1,12 @@ package config_test import ( - "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" ) // envKeySentryDSN is the variable envSentryDSN reads in production. @@ -101,10 +101,10 @@ func TestEnvSentryDSN(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. + configtest.ClearEnv(t) + if tt.set { t.Setenv(envKeySentryDSN, tt.value) - } else { - require.NoError(t, os.Unsetenv(envKeySentryDSN)) } got, err := config.EnvSentryDSNForTest(envKeySentryDSN) diff --git a/internal/database/bootstrap_banner_test.go b/internal/database/bootstrap_banner_test.go index 7f7860d..d775546 100644 --- a/internal/database/bootstrap_banner_test.go +++ b/internal/database/bootstrap_banner_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "log/slog" + "os" "path/filepath" "strings" "testing" @@ -119,3 +120,26 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) { t, second, created, "an existing database is not new", ) } + +// TestZeroLengthDatabase_IsLoggedAsNew covers what +// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a +// zero-length file as an empty database, so a start on one is a first +// start, and it must say so exactly as a start with no file does. +func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, database.MainDBFileName) + require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm)) + + var out bytes.Buffer + + db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil))) + require.NoError(t, err) + require.NoError(t, db.Close()) + + assert.Contains( + t, out.String(), + `level=WARN msg="created a new, empty database" path=`+path, + ) +} diff --git a/internal/database/database.go b/internal/database/database.go index da8bd37..d0beff0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "io/fs" "log/slog" "os" "path/filepath" @@ -19,6 +18,7 @@ import ( "sneak.berlin/go/webhooker/internal/banner" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/datadir" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/logger" ) @@ -203,8 +203,7 @@ func (d *Database) connectTo(dataDir string) error { // Checked before opening, which creates the file. A DATA_DIR that // is unexpectedly empty -- its volume not mounted, say -- looks // exactly like a first start, so a new database is a warning. - _, statErr := os.Stat(dbPath) - created := errors.Is(statErr, fs.ErrNotExist) + created := missingOrEmpty(dbPath) // Opened through OpenSQLite so this handle carries the same WAL // journaling, busy timeout, immediate-transaction locking, and pool @@ -213,13 +212,15 @@ func (d *Database) connectTo(dataDir string) error { if err != nil { d.log.Error( "failed to open database", + "path", dbPath, "error", err, ) return err } - // Then use it with GORM + // Then use it with GORM. Its errors are SQLite's alone and name no + // file, so the path is added to them here. db, err := gorm.Open(sqlite.Dialector{ Conn: sqlDB, }, &gorm.Config{ @@ -229,10 +230,11 @@ func (d *Database) connectTo(dataDir string) error { if err != nil { d.log.Error( "failed to connect to database", + "path", dbPath, "error", err, ) - return err + return fmt.Errorf("connecting to %s: %w", dbPath, err) } d.db = db @@ -243,13 +245,26 @@ func (d *Database) connectTo(dataDir string) error { d.log.Info("connected to database", "path", dbPath) } - // Run migrations - return d.migrate() + err = d.migrate() + if err != nil { + return fmt.Errorf("migrating %s: %w", dbPath, err) + } + + err = parseModels( + db, &Setting{}, &User{}, &APIKey{}, + &Webhook{}, &Entrypoint{}, &Target{}, + ) + if err != nil { + return fmt.Errorf("connecting to %s: %w", dbPath, err) + } + + return nil } +// migrate applies the main database's SQL files. See +// internal/db/migrations. func (d *Database) migrate() error { - // Run GORM auto-migrations - err := d.Migrate() + err := migrations.Apply(d.db, migrations.Main) if err != nil { d.log.Error( "failed to run database migrations", @@ -264,6 +279,21 @@ func (d *Database) migrate() error { return nil } +// parseModels has GORM parse each model for db before the handle is +// shared. GORM parses a model the first time a handle uses it, and two +// goroutines doing that at once for models that refer to each other, +// as Webhook and Target do, race inside GORM. +func parseModels(db *gorm.DB, models ...any) error { + for _, model := range models { + err := (&gorm.Statement{DB: db}).Parse(model) + if err != nil { + return fmt.Errorf("parsing model %T: %w", model, err) + } + } + + return nil +} + // ensureAdminUser creates the bootstrap admin account when the // deployment has no users at all. func (d *Database) ensureAdminUser() error { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 3b6a939..88bf0ae 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -1,9 +1,15 @@ package database_test import ( + "bytes" "context" + "log/slog" + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" @@ -100,3 +106,22 @@ func TestDatabaseConnection(t *testing.T) { ) } } + +// TestOpen_UnreadableDatabaseIsNamed pins +// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot +// read webhooker.db, the error that stops the server and `webhooker +// resetpw` names the file, not only SQLite's own message. +func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, database.MainDBFileName) + require.NoError(t, os.WriteFile( + path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm, + )) + + _, err := database.Open(dir, slog.New(slog.DiscardHandler)) + require.Error(t, err) + assert.Contains(t, err.Error(), path) + assert.Contains(t, err.Error(), "file is not a database") +} diff --git a/internal/database/databasetest/databasetest.go b/internal/database/databasetest/databasetest.go new file mode 100644 index 0000000..f999355 --- /dev/null +++ b/internal/database/databasetest/databasetest.go @@ -0,0 +1,55 @@ +// Package databasetest builds a WebhookDBManager for tests in other +// packages. +package databasetest + +import ( + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/database" +) + +// NewWebhookDBManager creates a WebhookDBManager backed by the given +// data directory, logging at DEBUG to standard error. +func NewWebhookDBManager( + t *testing.T, dataDir string, +) *database.WebhookDBManager { + t.Helper() + + return NewWebhookDBManagerWithLogger( + t, + dataDir, + slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )), + ) +} + +// NewWebhookDBManagerWithLogger is NewWebhookDBManager with the +// logger supplied by the caller. The per-webhook databases this manager +// opens hand that logger to gormlog, so a test that needs to see the SQL +// the service emits can capture it. +// +// It is built through database.NewWebhookDBManager on a lifecycle that +// is never started, so nothing closes its databases but the caller. +func NewWebhookDBManagerWithLogger( + t *testing.T, dataDir string, log *slog.Logger, +) *database.WebhookDBManager { + t.Helper() + + mgr, err := database.NewWebhookDBManager( + fxtest.NewLifecycle(t), + database.WebhookDBManagerParams{ + Config: &config.Config{DataDir: dataDir}, + Logger: log, + }, + ) + require.NoError(t, err) + + return mgr +} diff --git a/internal/database/event_db_isolation.go b/internal/database/event_db_isolation.go index bd1abc7..202a2a4 100644 --- a/internal/database/event_db_isolation.go +++ b/internal/database/event_db_isolation.go @@ -2,7 +2,6 @@ package database import ( "fmt" - "log/slog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -18,14 +17,14 @@ const omitAssociationsCallback = "webhooker:omit_associations" // // A per-webhook database holds the event tier only, but Delivery // declares belongs-to Event and Target and the delivery engine fills -// both in memory before writing. Without this guard GORM upserts -// those parent rows here on the delivery and retry write paths, -// copying targets.config, which holds destination URLs and bearer -// credentials, into the file most likely to be backed up or handed -// to someone else. Registering the guard on the connection covers -// every write path, including writes inside a transaction and write -// paths added later. Every event-tier row this file holds is written -// explicitly, so nothing depends on the automatic save. +// both in memory before writing. Without this guard GORM upserts those +// parent rows here on the delivery and retry write paths, and the +// target, whose config holds destination URLs and bearer credentials, +// goes to a targets table this file does not have, which fails the +// write. Registering the guard on the connection +// covers every write path, including writes inside a transaction and +// write paths added later. Every event-tier row this file holds is +// written explicitly, so nothing depends on the automatic save. func omitAssociations(db *gorm.DB) error { omit := func(tx *gorm.DB) { tx.Statement.Omits = append( @@ -53,107 +52,3 @@ func omitAssociations(db *gorm.DB) error { return nil } - -// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows -// stamps into a per-webhook database once it has removed any leaked -// target rows *and* the VACUUM that removes their bytes has returned. -// Nothing else in the tree uses user_version, so 0 means "not swept -// by this build". -// -// The stamp, not the DELETE, is what records that a file is done. A -// DELETE commits on its own, so a sweep that is interrupted or whose -// VACUUM fails leaves a file whose rows are gone but whose credential -// bytes are still in the free pages -- indistinguishable, by row -// count, from a file that never leaked. Both leave the stamp unset, -// so the next open sweeps again. -const eventDBSweptVersion = 1 - -// purgeTargetRows deletes target rows that an earlier build's -// association upsert wrote into a per-webhook database, and rewrites -// the file so their bytes are gone with them. AutoMigrate creates a -// targets table in every one of these files because Delivery declares -// a belongs-to Target, but nothing in the event tier may put rows in -// it. The rows it did put there are junk, not history: they carry an -// empty webhook_id, and delivery rows resolve their target against -// the main database, so nothing here refers to them. -// -// The DELETE only unlinks the rows: modernc.org/sqlite leaves -// secure_delete at SQLite's default of off, so the credential bytes -// stay readable in the file's free pages and a backup of a swept file -// would still hand them over. VACUUM rewrites the file without them. -// -// This runs before every migration and is gated on -// eventDBSweptVersion, so a file pays for the rewrite once, on the -// first open that finds it unstamped, and every open after that is a -// PRAGMA read. A file this build created is stamped before its -// targets table exists, so it never vacuums at all. A failure here -// fails the open with the stamp left unset, so the sweep is retried -// rather than skipped -- a webhook whose file cannot be swept stays -// unusable instead of quietly serving from a file that still holds -// recoverable credentials. -func purgeTargetRows( - db *gorm.DB, log *slog.Logger, webhookID string, -) error { - var version int - - // Row().Scan, not (*gorm.DB).Scan: see internal/gormlog. - err := db.Raw("PRAGMA user_version").Row().Scan(&version) - if err != nil { - return fmt.Errorf( - "reading sweep marker of webhook database %s: %w", - webhookID, err, - ) - } - - if version >= eventDBSweptVersion { - return nil - } - - var purged int64 - - if db.Migrator().HasTable("targets") { - res := db.Exec("DELETE FROM targets") - if res.Error != nil { - return fmt.Errorf( - "purging target rows from webhook database %s: %w", - webhookID, res.Error, - ) - } - - purged = res.RowsAffected - - // Unconditional: a zero row count here does not mean there is - // nothing to remove, only that no *live* row is left. See - // eventDBSweptVersion. - err = db.Exec("VACUUM").Error - if err != nil { - return fmt.Errorf( - "purged %d leaked target rows from webhook database "+ - "%s but vacuuming it failed, so the deleted "+ - "target credentials are still recoverable from "+ - "the file; it stays marked unswept and the next "+ - "open retries: %w", - purged, webhookID, err, - ) - } - } - - err = db.Exec(fmt.Sprintf( - "PRAGMA user_version = %d", eventDBSweptVersion, - )).Error - if err != nil { - return fmt.Errorf( - "marking webhook database %s swept: %w", webhookID, err, - ) - } - - if purged > 0 { - log.Warn( - "purged leaked target rows from per-webhook database", - "webhook_id", webhookID, - "rows", purged, - ) - } - - return nil -} diff --git a/internal/database/event_db_isolation_test.go b/internal/database/event_db_isolation_test.go index 66c28e0..6f38dbb 100644 --- a/internal/database/event_db_isolation_test.go +++ b/internal/database/event_db_isolation_test.go @@ -1,391 +1,15 @@ package database_test import ( - "bytes" - "database/sql" - "fmt" - "os" - "path/filepath" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" ) -// testDataDirPerm is the mode the test data directory is created -// with. -const testDataDirPerm = 0o750 - -// eventDBDataDir returns a data directory that a WebhookDBManager -// can be pointed at. -func eventDBDataDir(t *testing.T) string { - t.Helper() - - dir := filepath.Join(t.TempDir(), "events") - require.NoError(t, os.MkdirAll(dir, testDataDirPerm)) - - return dir -} - -// openRawEventDB opens the per-webhook database file directly, -// without the manager, so a test can put a file on disk in a state -// the manager has to cope with, or inspect one afterwards. -func openRawEventDB( - t *testing.T, dataDir, webhookID string, -) *sql.DB { - t.Helper() - - path := filepath.Join( - dataDir, fmt.Sprintf("events-%s.db", webhookID), - ) - - sqlDB, err := sql.Open( - "sqlite", - fmt.Sprintf("file:%s?mode=rwc", path), - ) - require.NoError(t, err) - - t.Cleanup(func() { _ = sqlDB.Close() }) - - return sqlDB -} - -// eventDBFileBytes reads a per-webhook database file off disk, so a -// test can assert on what the file itself still holds rather than on -// what a query returns. -func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte { - t.Helper() - - //nolint:gosec // reads a file the test just created under t.TempDir() - raw, err := os.ReadFile(filepath.Join( - dataDir, fmt.Sprintf("events-%s.db", webhookID), - )) - require.NoError(t, err) - - return raw -} - -// eventDBUserVersion returns the PRAGMA user_version of a per-webhook -// database file, which is the marker purgeTargetRows stamps once it -// has swept and vacuumed. -func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int { - t.Helper() - - var version int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), "PRAGMA user_version", - ).Scan(&version)) - - return version -} - -// clearEventDBSweptMarker resets the sweep marker to 0, which is what -// a file written by a build without the sweep looks like. Tests that -// seed a leaked row have to create the file through the manager to -// get the real targets table shape, and that stamps it. -func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) { - t.Helper() - - _, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0") - require.NoError(t, err) -} - -// countTargetRows returns the number of rows in the targets table of -// a per-webhook database file, or -1 if the table does not exist. -func countTargetRows(t *testing.T, sqlDB *sql.DB) int { - t.Helper() - - var tables int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), - "SELECT count(*) FROM sqlite_master "+ - "WHERE type = 'table' AND name = 'targets'", - ).Scan(&tables)) - - if tables == 0 { - return -1 - } - - var rows int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), "SELECT count(*) FROM targets", - ).Scan(&rows)) - - return rows -} - -// TestOpenPurgesLeakedTargetRows covers the sweep for event -// databases written by a build that let GORM upsert target rows -// into them: opening the database clears them, and opening it again -// is a no-op. -func TestOpenPurgesLeakedTargetRows(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - // Create the file the way the application does, so the targets - // table has exactly the shape AutoMigrate gives it, then write - // a leaked row into it the way the association upsert did. - initial := database.NewTestWebhookDBManager(dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - `{"webhookUrl":"https://hooks.example/T000/B000/secret"}`, - ) - require.NoError(t, err) - require.Equal(t, 1, countTargetRows(t, seed)) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - mgr := database.NewTestWebhookDBManager(dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, check)) - assert.Equal( - t, 1, eventDBUserVersion(t, check), - "a completed sweep must mark the file so later opens skip it", - ) - require.NoError(t, check.Close()) - - // Idempotent: a second open leaves it at zero and does not - // error. - again := database.NewTestWebhookDBManager(dataDir) - - _, err = again.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, again.CloseAll()) - - recheck := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, recheck)) -} - -// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level -// that matters for a backup handed to someone else: the leaked -// credential must be gone from the raw bytes of the file, not merely -// unreachable by query. A bare DELETE unlinks the row and leaves the -// bytes readable in the free pages, so this fails without the VACUUM -// in purgeTargetRows. -func TestOpenPurgeRemovesCredentialBytes(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - credential := "T00000000/B00000000/" + uuid.New().String() - - initial := database.NewTestWebhookDBManager(dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - fmt.Sprintf( - `{"webhookUrl":"https://hooks.example/%s"}`, credential, - ), - ) - require.NoError(t, err) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - // The seed has to be in the file for its absence later to mean - // anything. - require.True( - t, - bytes.Contains( - eventDBFileBytes(t, dataDir, webhookID), - []byte(credential), - ), - "seeded credential is not in the file, so this test proves nothing", - ) - - mgr := database.NewTestWebhookDBManager(dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - assert.NotContains( - t, - string(eventDBFileBytes(t, dataDir, webhookID)), - credential, - "leaked credential is still recoverable from the raw file", - ) -} - -// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count -// cannot see: the rows are already deleted but the file was never -// vacuumed, because an earlier sweep died between the two or its -// VACUUM failed. The credential bytes are still recoverable, and the -// unset marker is the only thing that says so, so the next open must -// vacuum rather than conclude from the empty table that there is -// nothing to do. -func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - credential := "T00000000/B00000000/" + uuid.New().String() - - initial := database.NewTestWebhookDBManager(dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - fmt.Sprintf( - `{"webhookUrl":"https://hooks.example/%s"}`, credential, - ), - ) - require.NoError(t, err) - - // Exactly the state an interrupted sweep leaves: rows gone, - // marker unset, bytes still in the free pages. - _, err = seed.ExecContext(t.Context(), "DELETE FROM targets") - require.NoError(t, err) - require.Zero(t, countTargetRows(t, seed)) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - require.True( - t, - bytes.Contains( - eventDBFileBytes(t, dataDir, webhookID), - []byte(credential), - ), - "the deleted row's bytes must still be in the file, or this "+ - "test proves nothing", - ) - - mgr := database.NewTestWebhookDBManager(dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - assert.NotContains( - t, - string(eventDBFileBytes(t, dataDir, webhookID)), - credential, - "an interrupted sweep was not retried, so the credential is "+ - "still recoverable from the raw file", - ) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Equal(t, 1, eventDBUserVersion(t, check)) -} - -// TestOpenSkipsSweptDatabase covers the other half of the marker: a -// file this build created is marked without ever being vacuumed, and -// a marked file is not swept again. -func TestOpenSkipsSweptDatabase(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - mgr := database.NewTestWebhookDBManager(dataDir) - - _, err := mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - marked := openRawEventDB(t, dataDir, webhookID) - assert.Equal(t, 1, eventDBUserVersion(t, marked)) - - // A marked file is left alone, so a row written into it survives - // a reopen. Nothing writes target rows any more; this stands in - // for the sweep having run. - _, err = marked.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), "sentinel", "slack", `{}`, - ) - require.NoError(t, err) - require.NoError(t, marked.Close()) - - again := database.NewTestWebhookDBManager(dataDir) - - _, err = again.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, again.CloseAll()) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Equal( - t, 1, countTargetRows(t, check), - "a marked file must not be swept again", - ) -} - -// TestOpenSucceedsWithoutTargetsTable covers an existing event -// database that never grew a targets table. The sweep must not fail -// startup on it. -func TestOpenSucceedsWithoutTargetsTable(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err := seed.ExecContext( - t.Context(), - "CREATE TABLE events (id text PRIMARY KEY)", - ) - require.NoError(t, err) - require.NoError(t, seed.Close()) - - mgr := database.NewTestWebhookDBManager(dataDir) - - db, err := mgr.GetDB(webhookID) - require.NoError(t, err) - assert.NotNil(t, db) - require.NoError(t, mgr.CloseAll()) -} - // TestEventDBCreateOmitsAssociations covers the connection-level // guard directly: a Delivery carrying its Event and Target in // memory, written through the manager's handle, must store only the @@ -393,10 +17,9 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) { func TestEventDBCreateOmitsAssociations(t *testing.T) { t.Parallel() - dataDir := eventDBDataDir(t) webhookID := uuid.New().String() - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, t.TempDir()) db, err := mgr.GetDB(webhookID) require.NoError(t, err) @@ -431,8 +54,9 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) { require.NoError(t, db.Model(d). Update("status", database.DeliveryStatusDelivered). Error) - require.NoError(t, mgr.CloseAll()) - check := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, check)) + // The file has no targets table, so the create and update above + // would have failed had GORM tried to save the target. + assert.False(t, db.Migrator().HasTable(&database.Target{})) + require.NoError(t, mgr.CloseAll()) } diff --git a/internal/database/event_tier_indexes_test.go b/internal/database/event_tier_indexes_test.go index d25dca5..65c1375 100644 --- a/internal/database/event_tier_indexes_test.go +++ b/internal/database/event_tier_indexes_test.go @@ -13,10 +13,8 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that opening a -// per-webhook database that predates these indexes creates them. It -// stands in for an older database file by dropping the indexes -// AutoMigrate just created, then reopening the same file. +// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new +// per-webhook database has these indexes. func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) { t.Parallel() @@ -42,27 +40,9 @@ func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) { db, err := mgr.GetDB(webhookID) require.NoError(t, err) - // A fresh database has them. - for _, ix := range indexes { - require.True(t, db.Migrator().HasIndex(ix.model, ix.name)) - } - - // Stand in for a database file created before the indexes existed. - for _, ix := range indexes { - require.NoError(t, db.Migrator().DropIndex(ix.model, ix.name)) - require.False(t, db.Migrator().HasIndex(ix.model, ix.name)) - } - - // Drop the cached connection so the next open reopens the file and - // runs AutoMigrate against it, as a restart would. - require.NoError(t, mgr.CloseAll()) - - db, err = mgr.GetDB(webhookID) - require.NoError(t, err) - for _, ix := range indexes { assert.True(t, db.Migrator().HasIndex(ix.model, ix.name), - "opening the existing database should create %s", ix.name) + "a new database should have %s", ix.name) } } @@ -93,11 +73,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) { deliveries []database.Delivery results []database.DeliveryResult depths []struct{ Depth int } + removed []database.TargetTotals ) byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" - byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at= ?", + []database.DeliveryStatus{ + database.DeliveryStatusDelivered, + database.DeliveryStatusFailed, + }, since). + Group("target_id").Find(&byTarget), + "COVERING INDEX idx_deliveries_status "+ + "(status=? AND deleted_at=? AND finished_at>?)") + assertPlanUses(t, db, dry.Model(&database.Event{}). + Where("created_at >= ?", since).Count(&count), + "idx_events_deleted_at_created_at "+ + "(deleted_at=? AND created_at>?)") +} + +// TestResubmitCountUsesItsIndex does the same for the event log's count +// of the events resubmitted from each of a page's events (resubmitCounts +// in the handlers). It passes a full page of 25 ids: with an index on +// resubmitted_from_id alone, SQLite uses it for three ids and turns to +// the deleted_at index from five. +func TestResubmitCountUsesItsIndex(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + db, err := mgr.GetDB(uuid.New().String()) + require.NoError(t, err) + + dry := db.Session(&gorm.Session{DryRun: true}) + + page := make([]string, 25) + for i := range page { + page[i] = uuid.New().String() + } + + var counts []struct{ Total int } + + assertPlanUses(t, db, dry.Model(&database.Event{}). + Select("resubmitted_from_id, count(*) AS total"). + Where("resubmitted_from_id IN ?", page). + Group("resubmitted_from_id").Find(&counts), + "idx_events_resubmitted_from_id "+ + "(resubmitted_from_id=? AND deleted_at=?)") +} + +// TestEntrypointEventsUseTheirIndex does the same for the webhook +// page's count, for each entrypoint, of the events that arrived on its +// URL since the retention cutoff (addEntrypointEvents in the +// handlers), which must come from the index alone. It passes 25 +// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events. +func TestEntrypointEventsUseTheirIndex(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + db, err := mgr.GetDB(uuid.New().String()) + require.NoError(t, err) + + dry := db.Session(&gorm.Session{DryRun: true}) + + entrypoints := make([]string, 25) + for i := range entrypoints { + entrypoints[i] = uuid.New().String() + } + + var rows []struct{ Events int } + + assertPlanUses(t, db, dry.Model(&database.Event{}). + Select("entrypoint_id, count(*) AS events"). + Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", + entrypoints). + Where("created_at >= ?", time.Now()). + Group("entrypoint_id").Find(&rows), + "COVERING INDEX idx_events_entrypoint_id "+ + "(entrypoint_id=? AND deleted_at=? AND "+ + "resubmitted_from_id=? AND created_at>?)") } // assertPlanUses asserts that SQLite's plan for a statement GORM built diff --git a/internal/database/export_test.go b/internal/database/export_test.go index 7ad4280..38ab54c 100644 --- a/internal/database/export_test.go +++ b/internal/database/export_test.go @@ -29,6 +29,10 @@ func NewTestRetentionReaper( } } +// ExportReapBatchSize exposes how many expired events one retention +// transaction deletes. +const ExportReapBatchSize = reapBatchSize + // ExportSweep runs a single retention sweep synchronously for tests. func (r *RetentionReaper) ExportSweep(ctx context.Context) { r.sweep(ctx) diff --git a/internal/database/model_apikey.go b/internal/database/model_apikey.go index 5e8cee7..e39b101 100644 --- a/internal/database/model_apikey.go +++ b/internal/database/model_apikey.go @@ -15,6 +15,7 @@ type APIKey struct { Description string `json:"description"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` - // Relations - User User `json:"user,omitzero"` + // Relations. No model marshals the record it belongs to: + // User.APIKeys leads back here, and the JSON could loop. + User User `json:"-"` } diff --git a/internal/database/model_delivery.go b/internal/database/model_delivery.go index 0ebd2bf..5f22557 100644 --- a/internal/database/model_delivery.go +++ b/internal/database/model_delivery.go @@ -1,6 +1,10 @@ package database -import "gorm.io/gorm" +import ( + "time" + + "gorm.io/gorm" +) // DeliveryStatus represents the status of a delivery type DeliveryStatus string @@ -37,7 +41,7 @@ type Delivery struct { BaseModel EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` - TargetID string `gorm:"type:uuid;not null" json:"targetId"` + TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"` Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` // DeletedAt repeats the BaseModel field only to be the second column @@ -45,8 +49,21 @@ type Delivery struct { // gives. DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"` - // Relations - Event Event `json:"event,omitzero"` - Target Target `json:"target,omitzero"` + // FinishedAt is when the delivery became delivered or failed, and + // nil while it is pending or retrying. It and then TargetID end the + // status index, so the webhook page counts each target's deliveries + // that finished in a recent window by reading just that window from + // the index. + FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` + + // Replay is set on a delivery created by the event log's Replay + // action, so the pages can tell it from the delivery it repeats. + Replay bool `gorm:"not null;default:false" json:"replay"` + + // Relations. No model marshals the record it belongs to: + // Event.Deliveries and Target.Deliveries lead back here, and the + // JSON could loop. + Event Event `json:"-"` + Target Target `json:"-"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` } diff --git a/internal/database/model_delivery_result.go b/internal/database/model_delivery_result.go index 7bf1b88..e293320 100644 --- a/internal/database/model_delivery_result.go +++ b/internal/database/model_delivery_result.go @@ -23,6 +23,7 @@ type DeliveryResult struct { Error string `json:"error,omitempty"` Duration int64 `json:"durationMs"` // Duration in milliseconds - // Relations - Delivery Delivery `json:"delivery,omitzero"` + // Relations. No model marshals the record it belongs to: + // Delivery.DeliveryResults leads back here, and the JSON could loop. + Delivery Delivery `json:"-"` } diff --git a/internal/database/model_entrypoint.go b/internal/database/model_entrypoint.go index 3021f48..ce8e42c 100644 --- a/internal/database/model_entrypoint.go +++ b/internal/database/model_entrypoint.go @@ -15,6 +15,7 @@ type Entrypoint struct { Description string `json:"description"` Active bool `gorm:"default:true" json:"active"` - // Relations - Webhook Webhook `json:"webhook,omitzero"` + // Relations. No model marshals the record it belongs to: + // Webhook.Entrypoints leads back here, and the JSON could loop. + Webhook Webhook `json:"-"` } diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 347eaac..0279230 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -19,28 +19,40 @@ type Event struct { // narrows by a < only on the last column it uses. Its final delete // has no deleted_at condition and uses the index on created_at // alone. The other tables keep the unindexed BaseModel created_at. - CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"` - DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"` + // DeletedAt is also the second column of the resubmitted_from_id + // index, for the reason DeliveryResult gives. The entrypoint_id + // index, for the webhook page's entrypoint list, has it second too, + // resubmitted_from_id third, and created_at last, which the list + // compares with a range. + CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"` + DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"` - WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` - EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"` + WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` + EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` - // Request data + // Request data. RawQuery is the receiving request's query string + // as sent, without the leading "?". Method string `gorm:"not null" json:"method"` + RawQuery string `gorm:"type:text" json:"rawQuery"` Headers string `gorm:"type:text" json:"headers"` // JSON Body string `gorm:"type:text" json:"body"` ContentType string `json:"contentType"` + // BodyBytes is the size of Body in bytes, recorded when the event + // is stored, so that the recent events list, which reads only the + // start of each body, knows the whole body's size. + BodyBytes int64 `gorm:"not null" json:"bodyBytes"` + // ResubmittedFromID names the event this one was copied from by // an operator resubmit. It is nil for an event that arrived on - // the receiver, which is every event created before the column - // existed. It is not a foreign key: the source event can be + // the receiver. It is not a foreign key: the source event can be // reaped by retention while its copies remain, and the id is // kept as the record of where the copy came from either way. - ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"` + ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"` - // Relations - Webhook Webhook `json:"webhook,omitzero"` - Entrypoint Entrypoint `json:"entrypoint,omitzero"` + // Relations. No model marshals the record it belongs to, so + // Webhook and Entrypoint are left out of the JSON. + Webhook Webhook `json:"-"` + Entrypoint Entrypoint `json:"-"` Deliveries []Delivery `json:"deliveries,omitempty"` } diff --git a/internal/database/model_parent_test.go b/internal/database/model_parent_test.go new file mode 100644 index 0000000..2c9ee9e --- /dev/null +++ b/internal/database/model_parent_test.go @@ -0,0 +1,126 @@ +package database_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's +// reference to the record it belongs to is left out of the JSON, so a +// webhook and its targets cannot marshal each other in a loop, and that +// GORM still preloads that reference, since it ignores json tags. +func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + stored := database.Webhook{ + UserID: uuid.New().String(), + Name: testWebhookName, + Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}}, + Targets: []database.Target{{ + Name: "log", + Type: database.TargetTypeLog, + }}, + } + require.NoError(t, db.Create(&stored).Error) + + entrypointID := stored.Entrypoints[0].ID + targetID := stored.Targets[0].ID + + var webhook database.Webhook + + require.NoError(t, db. + Preload("Entrypoints.Webhook"). + Preload("Targets.Webhook"). + First(&webhook, "id = ?", stored.ID).Error) + + require.Len(t, webhook.Entrypoints, 1) + require.Len(t, webhook.Targets, 1) + assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID) + assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID) + + encoded := marshalModel(t, webhook) + + assert.Contains(t, encoded, entrypointID) + assert.Contains(t, encoded, targetID) + + // Each child holds the parent's id as its webhookId, so the parent + // is looked for by its own id field. + parentIDField := `"id":"` + stored.ID + `"` + + assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField) + assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField) + + var target database.Target + + require.NoError(t, db. + Preload("Webhook"). + First(&target, "id = ?", targetID).Error) + + assert.Equal(t, stored.ID, target.Webhook.ID) + + encoded = marshalModel(t, target) + + assert.Contains(t, encoded, stored.ID) + assert.NotContains(t, encoded, parentIDField) +} + +// TestModelsMarshalWithoutTheirParent covers the other references to a +// parent: each model is built with its parent set, and the parent's id +// must not appear in the JSON. +func TestModelsMarshalWithoutTheirParent(t *testing.T) { + t.Parallel() + + parent := database.BaseModel{ID: uuid.New().String()} + + cases := []struct { + name string + model any + }{ + { + name: "Webhook.User", + model: database.Webhook{User: database.User{BaseModel: parent}}, + }, + { + name: "APIKey.User", + model: database.APIKey{User: database.User{BaseModel: parent}}, + }, + { + name: "Delivery.Event", + model: database.Delivery{Event: database.Event{BaseModel: parent}}, + }, + { + name: "Delivery.Target", + model: database.Delivery{Target: database.Target{BaseModel: parent}}, + }, + { + name: "DeliveryResult.Delivery", + model: database.DeliveryResult{ + Delivery: database.Delivery{BaseModel: parent}, + }, + }, + { + name: "Event.Webhook", + model: database.Event{Webhook: database.Webhook{BaseModel: parent}}, + }, + { + name: "Event.Entrypoint", + model: database.Event{ + Entrypoint: database.Entrypoint{BaseModel: parent}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.NotContains(t, marshalModel(t, tc.model), parent.ID) + }) + } +} diff --git a/internal/database/model_target.go b/internal/database/model_target.go index 9c5f95f..c10127c 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -31,10 +31,10 @@ type Target struct { // For HTTP targets (max_retries=0 means fire-and-forget, // >0 enables retries with backoff) - MaxRetries int `json:"maxRetries,omitempty"` - MaxQueueSize int `json:"maxQueueSize,omitempty"` + MaxRetries int `json:"maxRetries,omitempty"` - // Relations - Webhook Webhook `json:"webhook,omitzero"` + // Relations. No model marshals the record it belongs to: + // Webhook.Targets leads back here, and the JSON could loop. + Webhook Webhook `json:"-"` Deliveries []Delivery `json:"deliveries,omitempty"` } diff --git a/internal/database/model_totals.go b/internal/database/model_totals.go new file mode 100644 index 0000000..0d82d92 --- /dev/null +++ b/internal/database/model_totals.go @@ -0,0 +1,136 @@ +package database + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +// The running totals in a webhook's event database keep the webhook +// page's lifetime figures right after retention has removed the rows +// they count, and let the page show them without counting every row. +// Each total changes in the transaction that writes or deletes the +// rows it counts. + +// EventTotals is the single row counting a webhook's events: every +// event ever stored, how many of them retention has deleted, and when +// the newest arrived, which retention leaves as it is. +type EventTotals struct { + ID int64 `gorm:"primaryKey"` + + Events int64 `gorm:"not null"` + EventsRemoved int64 `gorm:"not null"` + + // LastEventAt is when the newest event arrived, or nil before the + // first. + LastEventAt *time.Time +} + +// TableName names the table AddEventTotals updates. +func (EventTotals) TableName() string { + return "event_totals" +} + +// TargetTotals is one row per target counting its deliveries: every +// delivery ever created, how many became delivered and how many +// failed, and how many deliveries and failed deliveries retention has +// deleted. The webhook's delivery figures are these rows summed. +type TargetTotals struct { + TargetID string `gorm:"type:uuid;primaryKey"` + + Deliveries int64 `gorm:"not null"` + Delivered int64 `gorm:"not null"` + Failed int64 `gorm:"not null"` + + DeliveriesRemoved int64 `gorm:"not null"` + FailedRemoved int64 `gorm:"not null"` +} + +// TableName names the table AddTargetTotals updates. +func (TargetTotals) TableName() string { + return "target_totals" +} + +// EntrypointTotals is one row per entrypoint, created by the first +// event that arrives on its URL: when the newest such event arrived, +// which retention leaves as it is. A resubmitted copy did not arrive +// on the URL and does not change it. +type EntrypointTotals struct { + EntrypointID string `gorm:"type:uuid;primaryKey"` + + LastEventAt time.Time `gorm:"not null"` +} + +// TableName names the table AddEntrypointTotals updates. +func (EntrypointTotals) TableName() string { + return "entrypoint_totals" +} + +// AddEventTotals adds each count in add to the webhook's event totals, +// and records add.LastEventAt as when the newest event arrived if it is +// set. Call it on the transaction that writes or deletes the events it +// counts. +func AddEventTotals(tx *gorm.DB, add EventTotals) error { + err := tx.Exec( + `UPDATE event_totals SET + events = events + ?, + events_removed = events_removed + ?, + last_event_at = coalesce(?, last_event_at)`, + add.Events, add.EventsRemoved, add.LastEventAt, + ).Error + if err != nil { + return fmt.Errorf("adding to event totals: %w", err) + } + + return nil +} + +// AddTargetTotals adds each count in add to the totals of the target +// add.TargetID names, creating its row the first time. Call it on the +// transaction that writes or deletes the deliveries it counts. +func AddTargetTotals(tx *gorm.DB, add TargetTotals) error { + err := tx.Exec( + `INSERT INTO target_totals (target_id, deliveries, delivered, + failed, deliveries_removed, failed_removed) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (target_id) DO UPDATE SET + deliveries = deliveries + excluded.deliveries, + delivered = delivered + excluded.delivered, + failed = failed + excluded.failed, + deliveries_removed = + deliveries_removed + excluded.deliveries_removed, + failed_removed = failed_removed + excluded.failed_removed`, + add.TargetID, add.Deliveries, add.Delivered, + add.Failed, add.DeliveriesRemoved, add.FailedRemoved, + ).Error + if err != nil { + return fmt.Errorf( + "adding to totals of target %s: %w", add.TargetID, err, + ) + } + + return nil +} + +// AddEntrypointTotals records add.LastEventAt as when the newest event +// arrived on the URL of the entrypoint add.EntrypointID names, creating +// its row the first time. Call it on the transaction that stores the +// event. +func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error { + err := tx.Exec( + `INSERT INTO entrypoint_totals (entrypoint_id, last_event_at) + VALUES (?, ?) + ON CONFLICT (entrypoint_id) DO UPDATE SET + last_event_at = excluded.last_event_at`, + add.EntrypointID, add.LastEventAt, + ).Error + if err != nil { + return fmt.Errorf( + "adding to totals of entrypoint %s: %w", + add.EntrypointID, err, + ) + } + + return nil +} diff --git a/internal/database/model_user.go b/internal/database/model_user.go index ec2ca1e..2c95040 100644 --- a/internal/database/model_user.go +++ b/internal/database/model_user.go @@ -1,13 +1,58 @@ package database +import ( + "errors" + "fmt" + + "gorm.io/gorm" +) + +// MaxUsernameBytes is the longest username, in bytes, that a user may +// have. The same number appears in the check constraint on +// User.Username, because a struct tag cannot reference a constant. +// +// A login stores the username in the session cookie, and both +// securecookie and browsers refuse a cookie value past about 4096 +// bytes. That value is the session base64-encoded twice, so it holds +// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature, +// timestamp and the session's other values take about 270 of those: a +// username longer than about 2030 bytes can never log in. The limit is +// about half that, so the session can carry more values later without +// locking out an account whose username is already at the limit. +const MaxUsernameBytes = 1024 + +// ErrUsernameTooLong is returned when a user is saved with a username +// longer than MaxUsernameBytes. +var ErrUsernameTooLong = errors.New("username is too long") + // User represents a user of the webhooker service +// +//nolint:lll // a struct tag cannot wrap type User struct { BaseModel - Username string `gorm:"uniqueIndex;not null" json:"username"` - Password string `gorm:"not null" json:"-"` // Argon2 hashed + Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"` + Password string `gorm:"not null" json:"-"` // Argon2 hashed // Relations Webhooks []Webhook `json:"webhooks,omitempty"` APIKeys []APIKey `json:"apiKeys,omitempty"` } + +// BeforeSave rejects a username longer than MaxUsernameBytes when a whole +// User is created or saved, so those calls get ErrUsernameTooLong rather +// than the database's constraint error. A column update such as +// Update("username", ...) is caught only by the check constraint, as is +// any path that writes the table without this model. +func (u *User) BeforeSave(_ *gorm.DB) error { + if len(u.Username) > MaxUsernameBytes { + return fmt.Errorf( + "%w: %d bytes, limit is %d", + ErrUsernameTooLong, + len(u.Username), + MaxUsernameBytes, + ) + } + + return nil +} diff --git a/internal/database/model_user_test.go b/internal/database/model_user_test.go new file mode 100644 index 0000000..9d82b8b --- /dev/null +++ b/internal/database/model_user_test.go @@ -0,0 +1,65 @@ +package database_test + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// usernameAtLimit is exactly MaxUsernameBytes long, built from a +// two-byte character. A check that counted characters rather than bytes +// would see half the length and let the one-byte-longer name through. +func usernameAtLimit() string { + return strings.Repeat("é", database.MaxUsernameBytes/2) +} + +func TestUserCreate_RejectsOverlongUsername(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + err := db.Create(&database.User{ + Username: usernameAtLimit() + "x", + Password: "hash", + }).Error + + require.ErrorIs(t, err, database.ErrUsernameTooLong) +} + +func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + require.NoError(t, db.Create(&database.User{ + Username: usernameAtLimit(), + Password: "hash", + }).Error) +} + +// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw +// SQL, as a path that bypassed User.BeforeSave would, so only the +// table's check constraint stands between it and an over-long +// username. Accepting the name at the limit and refusing the next byte +// also pins the constraint's number to MaxUsernameBytes. +func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)" + + require.NoError(t, db.Exec( + insert, uuid.New().String(), usernameAtLimit(), "hash", + ).Error) + + err := db.Exec( + insert, uuid.New().String(), usernameAtLimit()+"x", "hash", + ).Error + require.Error(t, err) + assert.Contains(t, err.Error(), "CHECK constraint failed") +} diff --git a/internal/database/model_webhook.go b/internal/database/model_webhook.go index eedb192..bc0de7f 100644 --- a/internal/database/model_webhook.go +++ b/internal/database/model_webhook.go @@ -66,8 +66,9 @@ type Webhook struct { // must equal DefaultRetentionDays. RetentionDays int `gorm:"default:30" json:"retentionDays"` - // Relations - User User `json:"user,omitzero"` + // Relations. No model marshals the record it belongs to: + // User.Webhooks leads back here, and the JSON could loop. + User User `json:"-"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Targets []Target `json:"targets,omitempty"` } @@ -81,7 +82,7 @@ type Webhook struct { // value while building the insert statement, which runs after // BeforeSave; rewriting any later than this loses that race and the // row lands at 30 days. Living on the model also means a future call -// site — a REST API, a fixture, a migration — cannot bypass it. +// site, such as a REST API or a fixture, cannot bypass it. func (w *Webhook) BeforeSave(_ *gorm.DB) error { if w.RetentionDays <= 0 { w.RetentionDays = RetentionForeverDays @@ -97,8 +98,8 @@ func (w *Webhook) BeforeSave(_ *gorm.DB) error { // are exempt from reaping. // // It accepts the RetentionForeverDays sentinel written by BeforeSave -// and, defensively, the non-positive values that rows written before -// the sentinel existed may still carry. +// and, defensively, any non-positive value, which BeforeSave treats +// as a request to retain forever. func retainsForever(retentionDays int) bool { return retentionDays <= 0 || retentionDays >= RetentionForeverDays @@ -110,6 +111,13 @@ func (w *Webhook) RetainsForever() bool { return retainsForever(w.RetentionDays) } +// RetentionCutoff returns the time before which this webhook's events +// have expired, as the reaper computes it, and false when the webhook +// retains them forever. +func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) { + return retentionCutoff(now, w.RetentionDays) +} + // RetentionLabel returns the webhook's retention policy as display // text, so that no template has to know about the sentinel value. func (w *Webhook) RetentionLabel() string { diff --git a/internal/database/models.go b/internal/database/models.go deleted file mode 100644 index 0857a74..0000000 --- a/internal/database/models.go +++ /dev/null @@ -1,16 +0,0 @@ -package database - -// Migrate runs database migrations for the main application database. -// Only configuration-tier models are stored in the main database. -// Event-tier models (Event, Delivery, DeliveryResult) live in -// per-webhook dedicated databases managed by WebhookDBManager. -func (d *Database) Migrate() error { - return d.db.AutoMigrate( - &Setting{}, - &User{}, - &APIKey{}, - &Webhook{}, - &Entrypoint{}, - &Target{}, - ) -} diff --git a/internal/database/retention.go b/internal/database/retention.go index 13051af..5265ea9 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -18,6 +18,19 @@ import ( // computation. const hoursPerDay = 24 +// reapBatchSize is how many expired events one retention transaction +// deletes. A transaction holds the event database's write lock, which +// the receiver and the delivery workers wait for, so a large prune is +// split into transactions each short enough to finish well inside the +// busy timeout. +const reapBatchSize = 1000 + +// reapBatchPause is how long retention waits after one batch before +// starting the next. A writer waiting for the write lock checks for it +// again after at most 100 ms, so a longer pause lets it in between two +// batches instead of only after the whole prune. +const reapBatchPause = 200 * time.Millisecond + // RetentionReaperParams holds the fx dependencies for the // RetentionReaper. type RetentionReaperParams struct { @@ -171,32 +184,31 @@ func (r *RetentionReaper) sweep(ctx context.Context) { wh := webhooks[i] - // Skip retain-forever webhooks before building any query. - // RetainsForever covers both the RetentionForeverDays - // sentinel and the non-positive values that predate it: the - // sentinel is a positive number, so without this the reaper - // would compute a cutoff a thousand years in the past and - // issue a DELETE matching nothing on every single sweep. - if wh.RetainsForever() { - continue - } - - // Nothing to reap if the per-webhook database has never - // been created. + // A missing database has nothing to reap. Restart recovery + // reports a lost one (see WebhookDBManager.GetDB). if !r.dbManager.DBExists(wh.ID) { continue } - r.reapWebhook(wh.ID, wh.RetentionDays) + r.reapWebhook(ctx, wh.ID, wh.RetentionDays) } } // reapWebhook removes every expired event (and its dependents) from a -// single webhook's database. +// single webhook's database, or as many as it reaches before ctx is +// cancelled. func (r *RetentionReaper) reapWebhook( + ctx context.Context, webhookID string, retentionDays int, ) { + // A retain-forever webhook has no cutoff, so its database is not + // even opened. + cutoff, ok := retentionCutoff(time.Now(), retentionDays) + if !ok { + return + } + db, err := r.dbManager.GetDB(webhookID) if err != nil { r.log.Error( @@ -208,12 +220,7 @@ func (r *RetentionReaper) reapWebhook( return } - cutoff, ok := retentionCutoff(time.Now(), retentionDays) - if !ok { - return - } - - deleted, err := reapExpired(db, cutoff) + deleted, err := reapExpired(ctx, db, cutoff) if err != nil { r.log.Error( "retention sweep: failed to reap expired events", @@ -246,7 +253,7 @@ func (r *RetentionReaper) reapWebhook( // row in the database: the sweep then deletes every event, delivery, // and delivery result, including ones created seconds ago. Rejecting // out-of-range input at the form is the primary guard; saturating here -// means an old row, a migration, or a future call site cannot turn a +// means a value that reaches the row by any other path cannot turn a // too-large retention into total data loss. func retentionCutoff( now time.Time, @@ -265,57 +272,107 @@ func retentionCutoff( ), true } -// reapExpired hard-deletes, in foreign-key-safe order, the delivery -// results, deliveries, and events associated with events older than -// cutoff. Deletes are unscoped so rows are physically removed rather -// than soft-deleted, reclaiming disk. It returns the number of events -// deleted. -func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) { - // Fresh subqueries are built per statement to avoid reusing a - // mutated builder across executions. - expiredEventIDs := func() *gorm.DB { - return db.Model(&Event{}). - Select("id"). - Where("created_at < ?", cutoff) - } - expiredDeliveryIDs := func() *gorm.DB { - return db.Model(&Delivery{}). - Select("id"). - Where("event_id IN (?)", expiredEventIDs()) - } +// reapExpired hard-deletes the events older than cutoff, with their +// deliveries and delivery results, reapBatchSize events per +// transaction with reapBatchPause between transactions, until none is +// left. Once ctx is cancelled it returns after the batch in hand, +// leaving the rest to the next sweep, so stopping the app does not +// wait for a long prune. It returns the number of events deleted. +func reapExpired( + ctx context.Context, db *gorm.DB, cutoff time.Time, +) (int64, error) { + var total int64 - // 1. Delivery results whose delivery belongs to an expired event. - res := db.Unscoped(). - Where("delivery_id IN (?)", expiredDeliveryIDs()). - Delete(&DeliveryResult{}) - if res.Error != nil { - return 0, fmt.Errorf( - "deleting expired delivery results: %w", - res.Error, - ) - } + for { + var eventIDs []string - // 2. Deliveries belonging to an expired event. - del := db.Unscoped(). - Where("event_id IN (?)", expiredEventIDs()). - Delete(&Delivery{}) - if del.Error != nil { - return 0, fmt.Errorf( - "deleting expired deliveries: %w", - del.Error, - ) - } + err := db.Transaction(func(tx *gorm.DB) error { + err := tx.Unscoped().Model(&Event{}). + Where("created_at < ?", cutoff). + Limit(reapBatchSize). + Pluck("id", &eventIDs).Error + if err != nil { + return fmt.Errorf("selecting expired events: %w", err) + } - // 3. The expired events themselves. - ev := db.Unscoped(). - Where("created_at < ?", cutoff). - Delete(&Event{}) - if ev.Error != nil { - return 0, fmt.Errorf( - "deleting expired events: %w", - ev.Error, - ) - } + if len(eventIDs) == 0 { + return nil + } - return ev.RowsAffected, nil + return deleteEvents(tx, eventIDs) + }) + if err != nil { + return total, err + } + + total += int64(len(eventIDs)) + + if len(eventIDs) < reapBatchSize { + return total, nil + } + + select { + case <-ctx.Done(): + return total, nil + case <-time.After(reapBatchPause): + } + } +} + +// deleteEvents hard-deletes the given events and, in foreign-key-safe +// order before them, their delivery results and deliveries, then adds +// what it deleted to the running totals. It runs on reapExpired's +// transaction, so the totals change exactly when the rows do. Deletes +// are unscoped so rows are physically removed rather than +// soft-deleted, reclaiming disk. +func deleteEvents(tx *gorm.DB, eventIDs []string) error { + // 1. The delivery results of the events' deliveries. + err := tx.Unscoped(). + Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}). + Select("id"). + Where("event_id IN ?", eventIDs)). + Delete(&DeliveryResult{}).Error + if err != nil { + return fmt.Errorf("deleting expired delivery results: %w", err) + } + + // 2. The events' deliveries, after counting them, and the failed + // ones among them, per target. The status is tested in the select + // list rather than the WHERE clause: there, SQLite would read every + // failed delivery the webhook has through the status index, + // instead of only these through the event_id index. + var removed []TargetTotals + + err = tx.Unscoped().Model(&Delivery{}). + Select("target_id, count(*) AS deliveries_removed, "+ + "count(CASE WHEN status = ? THEN 1 END) AS failed_removed", + DeliveryStatusFailed). + Where("event_id IN ?", eventIDs). + Group("target_id"). + Find(&removed).Error + if err != nil { + return fmt.Errorf("counting expired deliveries: %w", err) + } + + err = tx.Unscoped(). + Where("event_id IN ?", eventIDs). + Delete(&Delivery{}).Error + if err != nil { + return fmt.Errorf("deleting expired deliveries: %w", err) + } + + // 3. The events themselves. + ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{}) + if ev.Error != nil { + return fmt.Errorf("deleting expired events: %w", ev.Error) + } + + for i := range removed { + err = AddTargetTotals(tx, removed[i]) + if err != nil { + return err + } + } + + return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected}) } diff --git a/internal/database/retention_test.go b/internal/database/retention_test.go index c2dccef..710ffc2 100644 --- a/internal/database/retention_test.go +++ b/internal/database/retention_test.go @@ -51,7 +51,7 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv { mgr, err := database.NewWebhookDBManager( lc, - database.WebhookDBManagerParams{Config: cfg, Logger: l}, + database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()}, ) require.NoError(t, err) @@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents( t, overflowingRetentionDays, database.RetentionForeverDays, - "the test value must not be rescued by the forever skip", + "the test value must not be treated as retain-forever", ) webhookID := createWebhook( diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go new file mode 100644 index 0000000..cc378ff --- /dev/null +++ b/internal/database/schema_test.go @@ -0,0 +1,168 @@ +package database_test + +import ( + "log/slog" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" +) + +// withFixedNow returns db with GORM's clock stopped at a time in UTC, +// so that the created and updated times it writes come back from +// SQLite equal to the row written, which holds them in UTC. +func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) { + at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC) + + return db.Session(&gorm.Session{ + NowFunc: func() time.Time { return at }, + }), at +} + +// newBaseModel returns a BaseModel with a new ID. +func newBaseModel() database.BaseModel { + return database.BaseModel{ID: uuid.New().String()} +} + +// assertRoundTrip writes row, which must be the only row of its +// table, and reads back the same row. +func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) { + t.Helper() + + require.NoError(t, db.Create(row).Error) + + var got T + + require.NoError(t, db.First(&got).Error) + assert.Equal(t, *row, got) +} + +// TestMainSchemaHoldsEveryModel writes a row of each main database +// model, every field set, into a database made from its SQL files. +func TestMainSchemaHoldsEveryModel(t *testing.T) { + t.Parallel() + + mainDB, err := database.Open( + t.TempDir(), slog.New(slog.DiscardHandler), + ) + require.NoError(t, err) + + t.Cleanup(func() { _ = mainDB.Close() }) + + db, at := withFixedNow(mainDB.DB()) + + assertRoundTrip(t, db, &database.Setting{ + Key: "session_key", Value: "value", + }) + assertRoundTrip(t, db, &database.User{ + BaseModel: newBaseModel(), + Username: "user", + Password: "password hash", + }) + assertRoundTrip(t, db, &database.APIKey{ + BaseModel: newBaseModel(), + UserID: uuid.New().String(), + Key: "key", + Description: "API key description", + LastUsedAt: &at, + }) + assertRoundTrip(t, db, &database.Webhook{ + BaseModel: newBaseModel(), + UserID: uuid.New().String(), + Name: "webhook", + Description: "webhook description", + RetentionDays: 7, + }) + assertRoundTrip(t, db, &database.Entrypoint{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Path: uuid.New().String(), + Description: "entrypoint description", + Active: true, + }) + assertRoundTrip(t, db, &database.Target{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Name: "target", + Type: database.TargetTypeHTTP, + Active: true, + Config: `{"url":"https://example.com/"}`, + MaxRetries: 3, + }) +} + +// TestEventSchemaHoldsEveryModel does the same for a webhook's event +// database, which the manager makes from its SQL files. +func TestEventSchemaHoldsEveryModel(t *testing.T) { + t.Parallel() + + mgr := databasetest.NewWebhookDBManager(t, t.TempDir()) + + t.Cleanup(func() { _ = mgr.CloseAll() }) + + events, err := mgr.GetDB(uuid.New().String()) + require.NoError(t, err) + + db, at := withFixedNow(events) + source := uuid.New().String() + + assertRoundTrip(t, db, &database.Event{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + RawQuery: "a=1", + Headers: `{"X":"Y"}`, + Body: "body", + ContentType: "text/plain", + BodyBytes: 4, + ResubmittedFromID: &source, + }) + assertRoundTrip(t, db, &database.Delivery{ + BaseModel: newBaseModel(), + EventID: uuid.New().String(), + TargetID: uuid.New().String(), + Status: database.DeliveryStatusDelivered, + FinishedAt: &at, + Replay: true, + }) + assertRoundTrip(t, db, &database.DeliveryResult{ + BaseModel: newBaseModel(), + DeliveryID: uuid.New().String(), + AttemptNum: 2, + Success: true, + StatusCode: 200, + ResponseBody: "ok", + Error: "error", + Duration: 15, + }) + assertRoundTrip(t, db, &database.TargetTotals{ + TargetID: uuid.New().String(), + Deliveries: 5, + Delivered: 3, + Failed: 1, + DeliveriesRemoved: 2, + FailedRemoved: 1, + }) + assertRoundTrip(t, db, &database.EntrypointTotals{ + EntrypointID: uuid.New().String(), LastEventAt: at, + }) + + // A new event database has its one row of event totals already. + require.NoError(t, database.AddEventTotals(db, database.EventTotals{ + Events: 3, EventsRemoved: 1, LastEventAt: &at, + })) + + var totals []database.EventTotals + + require.NoError(t, db.Find(&totals).Error) + assert.Equal(t, []database.EventTotals{{ + ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at, + }}, totals) +} diff --git a/internal/database/sqlite_mode_test.go b/internal/database/sqlite_mode_test.go index 3f75a14..f75c3fa 100644 --- a/internal/database/sqlite_mode_test.go +++ b/internal/database/sqlite_mode_test.go @@ -182,6 +182,29 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) { requireDatabaseSetOwnerOnly(t, path) } +// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place +// of -wal or -shm. Beside a -shm directory SQLite opens the database +// read-only without a word, and every write then fails naming no file, +// so the open must stop instead, naming the directory. +func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) { + t.Parallel() + + for _, suffix := range []string{"-wal", "-shm"} { + t.Run(suffix, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), database.MainDBFileName) + require.NoError(t, os.Mkdir(path+suffix, 0o700)) + + _, err := database.OpenSQLite( + path, database.SQLiteModeCreate, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), path+suffix) + }) + } +} + // TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism // the fix uses: OpenSQLite now creates the database file itself, and // must not do so for a caller that asked for an existing database. An diff --git a/internal/database/sqlite_open.go b/internal/database/sqlite_open.go index 9003efe..ab6d368 100644 --- a/internal/database/sqlite_open.go +++ b/internal/database/sqlite_open.go @@ -7,6 +7,7 @@ import ( "io/fs" "net/url" "os" + "syscall" "time" _ "modernc.org/sqlite" // Pure Go SQLite driver @@ -93,7 +94,8 @@ const ( const SQLiteFilePerm fs.FileMode = 0o600 // reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever -// touches it, and tightens any sidecar already on disk. +// touches it, and tightens any sidecar already on disk. A directory in +// place of any of them is an error naming it. // // The mode has to be settled here rather than by a chmod after opening, // because SQLite picks it: robust_open substitutes @@ -116,9 +118,8 @@ const SQLiteFilePerm fs.FileMode = 0o600 // SQLite still reports the absence rather than this function // materializing an empty database the caller asked not to create. // -// Chmod of a file that already exists is what tightens a data -// directory an earlier build left at 0644 — including a developer's -// own scratch directory — without any migration machinery. +// The mode is set on every open, whoever created the file, so a +// database or sidecar found at a wider mode is tightened too. func reserveSQLiteFile(path string, create bool) error { if create { // gosec G304: the path is the database file the caller asked @@ -143,7 +144,15 @@ func reserveSQLiteFile(path string, create bool) error { for _, p := range append( []string{path}, sqliteSidecarPaths(path)..., ) { - err := os.Chmod(p, SQLiteFilePerm) + // Chmod accepts a directory, and SQLite opens a database whose + // -shm is one read-only, without a word: every write then + // fails naming no file. + info, err := os.Stat(p) + if err == nil && info.IsDir() { + return fmt.Errorf("securing %s: %w", p, syscall.EISDIR) + } + + err = os.Chmod(p, SQLiteFilePerm) if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("securing %s: %w", p, err) } @@ -152,6 +161,20 @@ func reserveSQLiteFile(path string, create bool) error { return nil } +// missingOrEmpty reports whether opening path in SQLiteModeCreate +// would start a new, empty database: the file is not there, or it is +// zero-length, which SQLite opens as an empty database. A file left at +// zero length by an interrupted first start or a truncated copy holds +// as little as a missing one, and must be reported the same way. +func missingOrEmpty(path string) bool { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return true + } + + return err == nil && info.Size() == 0 +} + // sqliteSidecarPaths returns the files SQLite maintains beside a // database under WAL. They carry the same rows as the database itself, // so a fix that tightens only the main file has fixed nothing. @@ -218,7 +241,19 @@ func SQLiteDSN(path, mode string) string { ) q.Add("_pragma", "journal_mode(WAL)") - return "file:" + path + "?" + q.Encode() + // SQLite reads a file: URI's path only up to the first ? or #, and + // decodes %XX escapes in it, so url.URL escapes the path. OmitHost + // keeps a relative path as "file:data/x.db"; written + // "file://data/x.db", SQLite would reject "data" as the URI's + // authority. + dsn := url.URL{ + Scheme: "file", + Path: path, + OmitHost: true, + RawQuery: q.Encode(), + } + + return dsn.String() } // OpenSQLite opens the SQLite file at path with the service's diff --git a/internal/database/sqlite_open_test.go b/internal/database/sqlite_open_test.go index 448507c..b16aa12 100644 --- a/internal/database/sqlite_open_test.go +++ b/internal/database/sqlite_open_test.go @@ -2,6 +2,8 @@ package database_test import ( "context" + "database/sql" + "os" "path/filepath" "strings" "testing" @@ -112,6 +114,79 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) { assert.Equal(t, "10000", busy) } +// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name +// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or +// #, and decodes %41 to A, so an unescaped path sent every database in +// that directory to one file outside it. +func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "hooks#1?x%41") + require.NoError(t, os.Mkdir(dir, 0o700)) + + for _, name := range []string{ + database.MainDBFileName, + "events-" + uuid.New().String() + ".db", + } { + path := filepath.Join(dir, name) + + sqlDB, err := database.OpenSQLite( + path, database.SQLiteModeCreate, + ) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) }) + + assertOpenedFile(t, sqlDB, path) + } +} + +// TestOpenSQLiteOpensARelativePath opens a database at a path relative +// to the working directory, which is what a relative DATA_DIR such as +// the README's DATA_DIR=../webhooker-data gives OpenSQLite. +// +//nolint:paralleltest // t.Chdir moves the whole process. +func TestOpenSQLiteOpensARelativePath(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.Mkdir("data", 0o700)) + + path := filepath.Join("data", database.MainDBFileName) + + sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate) + require.NoError(t, err) + + defer func() { require.NoError(t, sqlDB.Close()) }() + + assertOpenedFile(t, sqlDB, path) +} + +// assertOpenedFile checks that sqlDB is the SQLite file at path. +// database_list names the file SQLite actually opened, as an absolute +// path with symlinks resolved, so the files are compared, not the names. +func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) { + t.Helper() + + var ( + seq int + schema, file string + ) + + require.NoError(t, sqlDB. + QueryRowContext(t.Context(), "pragma database_list"). + Scan(&seq, &schema, &file)) + + asked, err := os.Stat(path) + require.NoError(t, err) + + opened, err := os.Stat(file) + require.NoError(t, err) + + assert.True( + t, os.SameFile(asked, opened), + "asked for %s, SQLite opened %s", path, file, + ) +} + // TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the // reproduction in // https://git.eeqj.de/sneak/webhooker/issues/256: an operator's diff --git a/internal/database/testing.go b/internal/database/testing.go deleted file mode 100644 index 72c8816..0000000 --- a/internal/database/testing.go +++ /dev/null @@ -1,47 +0,0 @@ -package database - -import ( - "log/slog" - "os" - - "gorm.io/gorm" -) - -// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB. -// Intended for use in tests that need a *database.Database without the -// full fx lifecycle. The caller is responsible for closing the underlying -// sql.DB connection. -func NewTestDatabase(db *gorm.DB) *Database { - return &Database{ - db: db, - log: slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - } -} - -// NewTestWebhookDBManager creates a WebhookDBManager backed by the given -// data directory. Intended for use in tests without the fx lifecycle. -func NewTestWebhookDBManager(dataDir string) *WebhookDBManager { - return NewTestWebhookDBManagerWithLogger( - dataDir, - slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - ) -} - -// NewTestWebhookDBManagerWithLogger is NewTestWebhookDBManager with the -// logger supplied by the caller. The per-webhook databases this manager -// opens hand that logger to gormlog, so a test that needs to see the SQL -// the service emits can capture it. -func NewTestWebhookDBManagerWithLogger( - dataDir string, log *slog.Logger, -) *WebhookDBManager { - return &WebhookDBManager{ - dataDir: dataDir, - log: log, - } -} diff --git a/internal/database/totals_test.go b/internal/database/totals_test.go new file mode 100644 index 0000000..12f8720 --- /dev/null +++ b/internal/database/totals_test.go @@ -0,0 +1,417 @@ +package database_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// readEventTotals reads a webhook database's row of event totals, +// asserting that it has exactly one. +func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals { + t.Helper() + + var rows []database.EventTotals + + require.NoError(t, db.Find(&rows).Error) + require.Len(t, rows, 1) + + return rows[0] +} + +// readTargetTotals reads a webhook database's target totals, keyed by +// target. +func readTargetTotals( + t *testing.T, db *gorm.DB, +) map[string]database.TargetTotals { + t.Helper() + + var rows []database.TargetTotals + + require.NoError(t, db.Find(&rows).Error) + + byTarget := make(map[string]database.TargetTotals, len(rows)) + for _, row := range rows { + byTarget[row.TargetID] = row + } + + return byTarget +} + +// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event +// database starts with one row of zero event totals and no target +// totals, that adding to a target twice adds to the one row, and that +// opening the database again keeps everything added. +func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + + fresh := readEventTotals(t, db) + assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh) + assert.Empty(t, readTargetTotals(t, db)) + + first, second := uuid.New().String(), uuid.New().String() + + require.NoError(t, database.AddEventTotals(db, database.EventTotals{ + Events: 2, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: first, Deliveries: 2, Delivered: 1, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: first, Failed: 1, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: second, Deliveries: 1, + })) + + // Drop the cached connection so the next open reopens the file, + // as a restart would. + require.NoError(t, mgr.CloseAll()) + + db, err = mgr.GetDB(webhookID) + require.NoError(t, err) + + assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2}, + readEventTotals(t, db)) + assert.Equal(t, map[string]database.TargetTotals{ + first: { + TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1, + }, + second: {TargetID: second, Deliveries: 1}, + }, readTargetTotals(t, db)) +} + +// seedExpiredEvents stores count events created at the given time, +// each with a delivered delivery to one target and a failed delivery +// to the other, and one attempt for each delivery. +// +// It and seedBareEvents insert 50 rows per statement, not more. The +// SQLite driver looks up each parameter's value by scanning the +// statement's arguments from the first until it reaches that +// parameter's, so the time to bind a statement grows with the square of +// its parameter count: at 500 rows, several thousand parameters, the +// seeding took most of these tests' time under -race. +func seedExpiredEvents( + t *testing.T, + db *gorm.DB, + webhookID string, + count int, + createdAt time.Time, + delivered, failed string, +) { + t.Helper() + + events := make([]database.Event, count) + deliveries := make([]database.Delivery, 0, 2*count) + + for i := range events { + events[i] = database.Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + } + events[i].ID = uuid.New().String() + events[i].CreatedAt = createdAt + + deliveries = append(deliveries, + database.Delivery{ + EventID: events[i].ID, + TargetID: delivered, + Status: database.DeliveryStatusDelivered, + }, + database.Delivery{ + EventID: events[i].ID, + TargetID: failed, + Status: database.DeliveryStatusFailed, + }, + ) + } + + require.NoError(t, db.CreateInBatches(events, 50).Error) + require.NoError(t, db.CreateInBatches(deliveries, 50).Error) + + results := make([]database.DeliveryResult, len(deliveries)) + for i := range deliveries { + results[i] = database.DeliveryResult{ + DeliveryID: deliveries[i].ID, AttemptNum: 1, + } + } + + require.NoError(t, db.CreateInBatches(results, 50).Error) +} + +// seedBareEvents stores count events created at the given time, with +// no deliveries. +func seedBareEvents( + t *testing.T, + db *gorm.DB, + webhookID string, + count int, + createdAt time.Time, +) { + t.Helper() + + events := make([]database.Event, count) + for i := range events { + events[i] = database.Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + } + events[i].CreatedAt = createdAt + } + + require.NoError(t, db.CreateInBatches(events, 50).Error) +} + +// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune +// larger than one transaction's batch removes every expired event with +// its deliveries and delivery results, keeps the recent event, and +// adds what it removed to the event and target totals, so the totals +// within retention match the rows still stored. +func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) { + t.Parallel() + + env := setupRetentionTest(t) + + webhookID := createWebhook(t, env.mainDB.DB(), 30) + + db, err := env.mgr.GetDB(webhookID) + require.NoError(t, err) + + expired := database.ExportReapBatchSize + 1 + delivered, failed := uuid.New().String(), uuid.New().String() + seedExpiredEvents(t, db, webhookID, expired, + time.Now().Add(-40*24*time.Hour), delivered, failed) + + // One recent event, delivered to the first target. + recent := seedEventChain(t, db, webhookID, time.Now()) + require.NoError(t, db.Model(&database.Delivery{}). + Where("id = ?", recent.deliveryID). + Update("target_id", delivered).Error) + + // The totals storing those rows would have left. + n := int64(expired) + require.NoError(t, database.AddEventTotals(db, database.EventTotals{ + Events: n + 1, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: delivered, Deliveries: n + 1, Delivered: n + 1, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: failed, Deliveries: n, Failed: n, + })) + + env.reaper.ExportSweep(context.Background()) + + // Only the recent event's rows are left. + for _, model := range []any{ + &database.Event{}, &database.Delivery{}, &database.DeliveryResult{}, + } { + var count int64 + + require.NoError(t, db.Model(model).Count(&count).Error) + assert.Equal(t, int64(1), count, "%T rows left", model) + } + + assertChainPresent(t, db, recent) + + eventTotals := readEventTotals(t, db) + assert.Equal(t, database.EventTotals{ + ID: eventTotals.ID, Events: n + 1, EventsRemoved: n, + }, eventTotals) + + targetTotals := readTargetTotals(t, db) + assert.Equal(t, map[string]database.TargetTotals{ + delivered: { + TargetID: delivered, Deliveries: n + 1, Delivered: n + 1, + DeliveriesRemoved: n, + }, + failed: { + TargetID: failed, Deliveries: n, Failed: n, + DeliveriesRemoved: n, FailedRemoved: n, + }, + }, targetTotals) + + // A sweep with nothing left to remove changes nothing. + env.reaper.ExportSweep(context.Background()) + + assert.Equal(t, eventTotals, readEventTotals(t, db)) + assert.Equal(t, targetTotals, readTargetTotals(t, db)) +} + +// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune +// of several batches lets other writers in between its batches: an +// event stored once the first batch is deleted is stored while expired +// events are still left, not only after the prune has finished. +func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) { + t.Parallel() + + env := setupRetentionTest(t) + + webhookID := createWebhook(t, env.mainDB.DB(), 30) + + db, err := env.mgr.GetDB(webhookID) + require.NoError(t, err) + + // Three batches of expired events, with nothing else stored: only + // the number of batches matters here. + expired := 3 * database.ExportReapBatchSize + seedBareEvents(t, db, webhookID, expired, + time.Now().Add(-40*24*time.Hour)) + + cutoff := time.Now().Add(-30 * 24 * time.Hour) + countExpired := func() int64 { + var count int64 + + require.NoError(t, db.Model(&database.Event{}). + Where("created_at < ?", cutoff). + Count(&count).Error) + + return count + } + + pruned := make(chan struct{}) + + go func() { + defer close(pruned) + + env.reaper.ExportSweep(context.Background()) + }() + + t.Cleanup(func() { <-pruned }) + + // Every stored event is expired until the write below. + require.Eventually(t, func() bool { + var count int64 + + err := db.Model(&database.Event{}).Count(&count).Error + + return err == nil && count < int64(expired) + }, 10*time.Second, 10*time.Millisecond) + + event := &database.Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + } + require.NoError(t, db.Create(event).Error) + + assert.Positive(t, countExpired(), + "the event was stored only after the whole prune") + + <-pruned + + assert.Zero(t, countExpired()) + + var stored database.Event + + require.NoError(t, db.First(&stored, "id = ?", event.ID).Error) +} + +// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that +// stopping the reaper during a prune of several batches returns +// between two batches, well inside the stop timeout, leaving the +// remaining expired events for the next sweep, and that the totals +// match the rows left. +func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) { + t.Parallel() + + env := setupRetentionTest(t) + + webhookID := createWebhook(t, env.mainDB.DB(), 30) + + db, err := env.mgr.GetDB(webhookID) + require.NoError(t, err) + + // Two batches and one more of expired events, a few of them with a + // delivered and a failed delivery for the target totals to count. + // Most carry nothing else, to keep the test quick. + const withDeliveries = 10 + + expiredAt := time.Now().Add(-40 * 24 * time.Hour) + delivered, failed := uuid.New().String(), uuid.New().String() + seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt, + delivered, failed) + seedBareEvents(t, db, webhookID, + 2*database.ExportReapBatchSize+1-withDeliveries, expiredAt) + + n := int64(2*database.ExportReapBatchSize + 1) + require.NoError(t, database.AddEventTotals(db, database.EventTotals{ + Events: n, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: delivered, Deliveries: withDeliveries, + Delivered: withDeliveries, + })) + require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{ + TargetID: failed, Deliveries: withDeliveries, + Failed: withDeliveries, + })) + + env.reaper.ExportSetInterval(time.Millisecond) + env.reaper.ExportStart() + + // Stop once the first batch is deleted. The stop lands in the pause + // after it, or at worst during the second batch, so at least the + // last event is left. + require.Eventually(t, func() bool { + var count int64 + + err := db.Model(&database.Event{}).Count(&count).Error + + return err == nil && count < n + }, 10*time.Second, 10*time.Millisecond) + + // The app's stop timeout. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + require.NoError(t, env.reaper.ExportStop(ctx)) + + var events int64 + + require.NoError(t, db.Model(&database.Event{}).Count(&events).Error) + assert.Positive(t, events, "the stop waited for the whole prune") + + eventTotals := readEventTotals(t, db) + assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved) + + targetTotals := readTargetTotals(t, db) + require.Len(t, targetTotals, 2) + + for target, totals := range targetTotals { + var deliveries, failures int64 + + require.NoError(t, db.Model(&database.Delivery{}). + Where("target_id = ?", target). + Count(&deliveries).Error) + require.NoError(t, db.Model(&database.Delivery{}). + Where("target_id = ? AND status = ?", + target, database.DeliveryStatusFailed). + Count(&failures).Error) + + assert.Equal(t, deliveries, + totals.Deliveries-totals.DeliveriesRemoved, target) + assert.Equal(t, failures, totals.Failed-totals.FailedRemoved, + target) + } +} diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go index a628b56..7886457 100644 --- a/internal/database/webhook_db_manager.go +++ b/internal/database/webhook_db_manager.go @@ -14,8 +14,8 @@ import ( "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/datadir" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" - "sneak.berlin/go/webhooker/internal/logger" ) // WebhookDBManagerParams holds the fx dependencies for @@ -24,7 +24,7 @@ type WebhookDBManagerParams struct { fx.In Config *config.Config - Logger *logger.Logger + Logger *slog.Logger } // errInvalidCachedDBType indicates a type assertion failure @@ -33,9 +33,23 @@ var errInvalidCachedDBType = errors.New( "invalid cached database type", ) +// ErrEventDBNotRemoved is in DeleteDB's error when the event +// database file itself could not be removed: it is still on disk. +var ErrEventDBNotRemoved = errors.New( + "event database file not removed", +) + +// ErrSidecarNotRemoved is in DeleteDB's error when the event +// database file was removed, so its events are gone, but its -wal +// or -shm sidecar could not be. +var ErrSidecarNotRemoved = errors.New( + "event database file removed, but a -wal or -shm sidecar was not", +) + // WebhookDBManager manages per-webhook SQLite database files // for event storage. Each webhook gets its own dedicated -// database containing Events, Deliveries, and DeliveryResults. +// database containing Events, Deliveries, DeliveryResults and the +// running totals of them (EventTotals, TargetTotals, EntrypointTotals). // Database connections are opened lazily and cached. type WebhookDBManager struct { dataDir string @@ -56,7 +70,7 @@ func NewWebhookDBManager( ) (*WebhookDBManager, error) { m := &WebhookDBManager{ dataDir: params.Config.DataDir, - log: params.Logger.Get(), + log: params.Logger, } // Create data directory if it doesn't exist. datadir.DirPerm is the @@ -84,34 +98,37 @@ func NewWebhookDBManager( return m, nil } -// GetDB returns the database connection for a webhook, -// creating the database file lazily if it doesn't exist. +// GetDB returns the database connection for a webhook, opening it on +// first use. +// +// The file is made by CreateDB when the webhook is created. One that is +// missing or zero-length here means the webhook's events and pending +// deliveries are gone: an empty database is created in its place so +// the webhook keeps receiving, and that is logged as a warning naming +// the file, as a new main database is. func (m *WebhookDBManager) GetDB( webhookID string, ) (*gorm.DB, error) { - // Fast path: already open - if val, ok := m.dbs.Load(webhookID); ok { - return asGormDB(val, webhookID) - } + return m.getDB(webhookID, false) +} - // Slow path: open the database under the lock, looking in the - // cache again first. A caller that raced another one here then - // waits for its handle instead of opening a second one. +// GetDBIf is GetDB, done only when check reports true. check runs under +// the lock DeleteDB holds while it removes the files, so a caller can +// confirm the webhook still exists and open its database with no delete +// in between. The handle is nil when check reports false. check must +// not call the manager. +func (m *WebhookDBManager) GetDBIf( + webhookID string, check func() (bool, error), +) (*gorm.DB, error) { m.mu.Lock() defer m.mu.Unlock() - if val, ok := m.dbs.Load(webhookID); ok { - return asGormDB(val, webhookID) - } - - db, err := m.openDB(webhookID) - if err != nil { + ok, err := check() + if err != nil || !ok { return nil, err } - m.dbs.Store(webhookID, db) - - return db, nil + return m.getDBLocked(webhookID, false) } // asGormDB returns a value read from the cache as the database @@ -129,12 +146,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) { return db, nil } -// CreateDB explicitly creates a new per-webhook database file -// and runs migrations. +// CreateDB creates a new webhook's database file and runs +// migrations. func (m *WebhookDBManager) CreateDB( webhookID string, ) error { - _, err := m.GetDB(webhookID) + _, err := m.getDB(webhookID, true) return err } @@ -150,7 +167,10 @@ func (m *WebhookDBManager) DBExists( } // DeleteDB closes the connection and deletes the database file -// for a webhook. The file is permanently removed. +// for a webhook, with its -wal and -shm sidecars. The files are +// permanently removed. Each file is tried even when another could +// not be removed, and the error wraps ErrEventDBNotRemoved or +// ErrSidecarNotRemoved to say which was left, naming each file. func (m *WebhookDBManager) DeleteDB( webhookID string, ) error { @@ -169,16 +189,23 @@ func (m *WebhookDBManager) DeleteDB( } } - // Delete the main DB file and WAL/SHM files path := m.dbPath(webhookID) - for _, suffix := range []string{"", "-wal", "-shm"} { - err := os.Remove(path + suffix) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf( - "deleting webhook database file %s%s: %w", - path, suffix, err, - ) - } + + dbErr := removeFile(path) + sidecarErr := errors.Join( + removeFile(path+"-wal"), + removeFile(path+"-shm"), + ) + + if dbErr != nil { + return fmt.Errorf( + "%w: %w", + ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr), + ) + } + + if sidecarErr != nil { + return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr) } m.log.Info( @@ -189,6 +216,17 @@ func (m *WebhookDBManager) DeleteDB( return nil } +// removeFile removes path. A file that is already gone counts as +// removed; the error from any other failure names the file. +func removeFile(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err +} + // CloseAll closes all open per-webhook database connections. // Called during application shutdown. func (m *WebhookDBManager) CloseAll() error { @@ -231,6 +269,54 @@ func (m *WebhookDBManager) DBPath( return m.dbPath(webhookID) } +// getDB is GetDB, and CreateDB when isNew is true: the webhook has just +// been created, so a missing file is expected rather than lost. +func (m *WebhookDBManager) getDB( + webhookID string, isNew bool, +) (*gorm.DB, error) { + // Fast path: already open + if val, ok := m.dbs.Load(webhookID); ok { + return asGormDB(val, webhookID) + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.getDBLocked(webhookID, isNew) +} + +// getDBLocked is getDB's slow path, run with m.mu held. It looks in the +// cache again first: a caller that raced another one to the lock then +// gets its handle instead of opening a second one. +func (m *WebhookDBManager) getDBLocked( + webhookID string, isNew bool, +) (*gorm.DB, error) { + if val, ok := m.dbs.Load(webhookID); ok { + return asGormDB(val, webhookID) + } + + // Checked before opening, which creates the file. See GetDB. + path := m.dbPath(webhookID) + replaced := !isNew && missingOrEmpty(path) + + db, err := m.openDB(webhookID) + if err != nil { + return nil, err + } + + if replaced { + m.log.Warn( + "created a new, empty database", + "webhook_id", webhookID, + "path", path, + ) + } + + m.dbs.Store(webhookID, db) + + return db, nil +} + func (m *WebhookDBManager) dbPath( webhookID string, ) string { @@ -285,17 +371,7 @@ func (m *WebhookDBManager) openDB( ) } - err = purgeTargetRows(db, m.log, webhookID) - if err != nil { - _ = sqlDB.Close() - - return nil, err - } - - // Run migrations for event-tier models only - err = db.AutoMigrate( - &Event{}, &Delivery{}, &DeliveryResult{}, - ) + err = migrations.Apply(db, migrations.Events) if err != nil { _ = sqlDB.Close() @@ -305,6 +381,19 @@ func (m *WebhookDBManager) openDB( ) } + err = parseModels( + db, &Event{}, &Delivery{}, &DeliveryResult{}, + &EventTotals{}, &TargetTotals{}, &EntrypointTotals{}, + ) + if err != nil { + _ = sqlDB.Close() + + return nil, fmt.Errorf( + "connecting to webhook database %s: %w", + webhookID, err, + ) + } + m.log.Info( "opened per-webhook database", "webhook_id", webhookID, diff --git a/internal/database/webhook_db_manager_test.go b/internal/database/webhook_db_manager_test.go index 29a788c..3c914d1 100644 --- a/internal/database/webhook_db_manager_test.go +++ b/internal/database/webhook_db_manager_test.go @@ -18,6 +18,7 @@ import ( "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -50,7 +51,7 @@ func setupTestWebhookDBManager( lc, database.WebhookDBManagerParams{ Config: cfg, - Logger: l, + Logger: l.Get(), }, ) require.NoError(t, err) @@ -117,7 +118,8 @@ func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) { var logs bytes.Buffer - mgr := database.NewTestWebhookDBManagerWithLogger( + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), ) @@ -182,17 +184,91 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) { } require.NoError(t, db.Create(event).Error) + // Under WAL, an open database that has been written to has both + // sidecars beside it. + dbPath := mgr.DBPath(webhookID) + require.FileExists(t, dbPath+"-wal") + require.FileExists(t, dbPath+"-shm") + // Delete the DB require.NoError(t, mgr.DeleteDB(webhookID)) // File should no longer exist assert.False(t, mgr.DBExists(webhookID)) - // Verify the file is actually gone from disk + // Verify the files are actually gone from disk + assert.NoFileExists(t, dbPath) + assert.NoFileExists(t, dbPath+"-wal") + assert.NoFileExists(t, dbPath+"-shm") +} + +// blockRemoval puts a non-empty directory at path, which os.Remove +// cannot remove whoever runs the test, root included. +func blockRemoval(t *testing.T, path string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700)) +} + +// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the +// event database file cannot be removed, the error says so, and both +// sidecars are still removed. +func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() dbPath := mgr.DBPath(webhookID) - _, err = os.Stat(dbPath) - assert.True(t, os.IsNotExist(err)) + blockRemoval(t, dbPath) + require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600)) + require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600)) + + err := mgr.DeleteDB(webhookID) + + require.ErrorIs(t, err, database.ErrEventDBNotRemoved) + require.NotErrorIs(t, err, database.ErrSidecarNotRemoved) + assert.Contains(t, err.Error(), dbPath) + assert.NoFileExists(t, dbPath+"-wal") + assert.NoFileExists(t, dbPath+"-shm") +} + +// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the +// event database file is removed but a sidecar is not, the error +// says the database file is gone, and the other sidecar is still +// removed. +func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + dbPath := mgr.DBPath(webhookID) + + require.NoError(t, mgr.CreateDB(webhookID)) + // Closing removes the sidecars, so the ones below are the only + // ones there. + require.NoError(t, mgr.CloseAll()) + + blockRemoval(t, dbPath+"-wal") + require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600)) + + err := mgr.DeleteDB(webhookID) + + require.ErrorIs(t, err, database.ErrSidecarNotRemoved) + require.NotErrorIs(t, err, database.ErrEventDBNotRemoved) + assert.Contains(t, err.Error(), dbPath+"-wal") + assert.NoFileExists(t, dbPath) + assert.NoFileExists(t, dbPath+"-shm") } func TestWebhookDBManager_LazyCreation(t *testing.T) { @@ -215,6 +291,76 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) { assert.True(t, mgr.DBExists(webhookID)) } +// A webhook's database is made by CreateDB along with the webhook. One +// that GetDB finds missing or zero-length has lost the webhook's events +// and pending deliveries, so the empty database made in its place is +// logged as a warning naming the file +// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and +// reopening a database that is there, log no such warning. +func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) { + t.Parallel() + + const created = `level=WARN msg="created a new, empty database"` + + open := func( + t *testing.T, prepare func(*database.WebhookDBManager, string), + ) (string, string) { + t.Helper() + + var logs bytes.Buffer + + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, + t.TempDir(), + slog.New(slog.NewTextHandler(&logs, nil)), + ) + + webhookID := uuid.New().String() + prepare(mgr, webhookID) + + _, err := mgr.GetDB(webhookID) + require.NoError(t, err) + require.NoError(t, mgr.CloseAll()) + + return logs.String(), + " webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID) + } + + t.Run("missing", func(t *testing.T) { + t.Parallel() + + logs, fields := open( + t, func(*database.WebhookDBManager, string) {}, + ) + assert.Contains(t, logs, created+fields) + }) + + t.Run("zero-length", func(t *testing.T) { + t.Parallel() + + logs, fields := open( + t, func(mgr *database.WebhookDBManager, webhookID string) { + require.NoError(t, os.WriteFile( + mgr.DBPath(webhookID), nil, database.SQLiteFilePerm, + )) + }, + ) + assert.Contains(t, logs, created+fields) + }) + + t.Run("created with the webhook, then reopened", func(t *testing.T) { + t.Parallel() + + logs, _ := open( + t, func(mgr *database.WebhookDBManager, webhookID string) { + require.NoError(t, mgr.CreateDB(webhookID)) + require.NoError(t, mgr.CloseAll()) + }, + ) + assert.NotContains(t, logs, created) + }) +} + func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) { t.Parallel() diff --git a/internal/db/migrations/archive/000_migration.sql b/internal/db/migrations/archive/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/archive/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/archive/001_schema.sql b/internal/db/migrations/archive/001_schema.sql new file mode 100644 index 0000000..ceff586 --- /dev/null +++ b/internal/db/migrations/archive/001_schema.sql @@ -0,0 +1,18 @@ +-- Migration 001: the schema of a database target's archive file. The +-- archivedEvent model in internal/delivery must agree with it. + +CREATE TABLE archived_events ( + id integer PRIMARY KEY AUTOINCREMENT, + event_id text, + webhook_id text, + entrypoint_id text, + method text, + raw_query text, + headers text, + body text, + content_type text, + archived_at datetime +); + +CREATE INDEX idx_archived_events_event_id ON archived_events (event_id); +CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at); diff --git a/internal/db/migrations/events/000_migration.sql b/internal/db/migrations/events/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/events/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/events/001_schema.sql b/internal/db/migrations/events/001_schema.sql new file mode 100644 index 0000000..20648dc --- /dev/null +++ b/internal/db/migrations/events/001_schema.sql @@ -0,0 +1,98 @@ +-- Migration 001: the schema of a webhook's event database. The models +-- in internal/database must agree with it. The webhook, entrypoint and +-- target ids here name rows in the main database, which has those +-- tables; this file does not. + +CREATE TABLE events ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + entrypoint_id uuid NOT NULL, + method text NOT NULL, + raw_query text, + headers text, + body text, + content_type text, + body_bytes integer NOT NULL, + resubmitted_from_id uuid, + PRIMARY KEY (id) +); + +CREATE INDEX idx_events_created_at ON events (created_at); +CREATE INDEX idx_events_deleted_at ON events (deleted_at); +CREATE INDEX idx_events_deleted_at_created_at + ON events (deleted_at, created_at); +CREATE INDEX idx_events_resubmitted_from_id + ON events (resubmitted_from_id, deleted_at); +CREATE INDEX idx_events_entrypoint_id + ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at); + +CREATE TABLE deliveries ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + event_id uuid NOT NULL, + target_id uuid NOT NULL, + status text NOT NULL DEFAULT 'pending', + finished_at datetime, + replay numeric NOT NULL DEFAULT false, + PRIMARY KEY (id), + CONSTRAINT fk_events_deliveries + FOREIGN KEY (event_id) REFERENCES events (id) +); + +CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at); +CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at); +CREATE INDEX idx_deliveries_status + ON deliveries (status, deleted_at, finished_at, target_id); + +CREATE TABLE delivery_results ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + delivery_id uuid NOT NULL, + attempt_num integer NOT NULL, + success numeric, + status_code integer, + response_body text, + error text, + duration integer, + PRIMARY KEY (id), + CONSTRAINT fk_deliveries_delivery_results + FOREIGN KEY (delivery_id) REFERENCES deliveries (id) +); + +CREATE INDEX idx_delivery_results_deleted_at + ON delivery_results (deleted_at); +CREATE INDEX idx_delivery_results_delivery_id + ON delivery_results (delivery_id, deleted_at); + +CREATE TABLE event_totals ( + id integer PRIMARY KEY AUTOINCREMENT, + events integer NOT NULL, + events_removed integer NOT NULL, + last_event_at datetime +); + +-- The one row of event totals, all zero. +INSERT INTO event_totals (events, events_removed) VALUES (0, 0); + +CREATE TABLE target_totals ( + target_id uuid, + deliveries integer NOT NULL, + delivered integer NOT NULL, + failed integer NOT NULL, + deliveries_removed integer NOT NULL, + failed_removed integer NOT NULL, + PRIMARY KEY (target_id) +); + +CREATE TABLE entrypoint_totals ( + entrypoint_id uuid, + last_event_at datetime NOT NULL, + PRIMARY KEY (entrypoint_id) +); diff --git a/internal/db/migrations/main/000_migration.sql b/internal/db/migrations/main/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/main/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/main/001_schema.sql b/internal/db/migrations/main/001_schema.sql new file mode 100644 index 0000000..4911a0d --- /dev/null +++ b/internal/db/migrations/main/001_schema.sql @@ -0,0 +1,89 @@ +-- Migration 001: the main database's schema. The models in +-- internal/database must agree with it. + +CREATE TABLE settings ( + "key" text, + value text NOT NULL, + PRIMARY KEY ("key") +); + +CREATE TABLE users ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + username text NOT NULL, + password text NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_users_username + CHECK (length(CAST(username AS BLOB)) <= 1024) +); + +CREATE INDEX idx_users_deleted_at ON users (deleted_at); +CREATE UNIQUE INDEX idx_users_username ON users (username); + +CREATE TABLE api_keys ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + user_id uuid NOT NULL, + "key" text NOT NULL, + description text, + last_used_at datetime, + PRIMARY KEY (id), + CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at); +CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key"); + +CREATE TABLE webhooks ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + user_id uuid NOT NULL, + name text NOT NULL, + description text, + retention_days integer DEFAULT 30, + PRIMARY KEY (id), + CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at); + +CREATE TABLE entrypoints ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + path text NOT NULL, + description text, + active numeric DEFAULT true, + PRIMARY KEY (id), + CONSTRAINT fk_webhooks_entrypoints + FOREIGN KEY (webhook_id) REFERENCES webhooks (id) +); + +CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at); +CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path); + +CREATE TABLE targets ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + name text NOT NULL, + type text NOT NULL, + active numeric DEFAULT true, + config text, + max_retries integer, + PRIMARY KEY (id), + CONSTRAINT fk_webhooks_targets + FOREIGN KEY (webhook_id) REFERENCES webhooks (id) +); + +CREATE INDEX idx_targets_deleted_at ON targets (deleted_at); diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go new file mode 100644 index 0000000..9351d62 --- /dev/null +++ b/internal/db/migrations/migrations.go @@ -0,0 +1,135 @@ +// Package migrations holds the schema of each kind of SQLite file +// webhooker writes, as numbered SQL files embedded in the binary, and +// applies it when a file is opened. +// +// Each kind of file has its own directory. In each, 000_migration.sql +// creates only the schema_migrations table, and 001_schema.sql holds +// the whole schema. Before 1.0 a schema change is an edit to +// 001_schema.sql, never a new file. +package migrations + +import ( + "embed" + "fmt" + "io/fs" + "path" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// The directories, one for each kind of database file. +const ( + // Main is the main database, webhooker.db. + Main = "main" + + // Events is a webhook's event database, events-{uuid}.db. + Events = "events" + + // Archive is a database target's archive file. + Archive = "archive" +) + +//go:embed main/*.sql events/*.sql archive/*.sql +var files embed.FS + +// Apply runs each SQL file in dir, which is Main, Events or Archive, +// that the database has not had yet, in the order of the number its +// name starts with, and records that number in schema_migrations. +// 000_migration.sql creates that table and records itself, so it runs +// first, and only when the table is missing. +// +// It all runs in one transaction, so a failure leaves the file as it +// was. +func Apply(db *gorm.DB, dir string) error { + return db.Transaction(func(tx *gorm.DB) error { + var tables int + + // Row().Scan, not (*gorm.DB).Scan: see internal/gormlog. + err := tx.Raw( + "SELECT count(*) FROM sqlite_master "+ + "WHERE type = 'table' AND name = ?", + "schema_migrations", + ).Row().Scan(&tables) + if err != nil { + return fmt.Errorf("looking for schema_migrations: %w", err) + } + + if tables == 0 { + err = run(tx, path.Join(dir, "000_migration.sql")) + if err != nil { + return err + } + } + + // fs.Glob lists the files in name order, which is number order. + names, err := fs.Glob(files, path.Join(dir, "*.sql")) + if err != nil { + return fmt.Errorf("listing migrations: %w", err) + } + + for _, name := range names { + err = applyOnce(tx, name) + if err != nil { + return err + } + } + + return nil + }) +} + +// applyOnce runs the migration file name and records its number, +// unless schema_migrations already has that number. +func applyOnce(tx *gorm.DB, name string) error { + number, _, _ := strings.Cut(path.Base(name), "_") + + version, err := strconv.Atoi(number) + if err != nil { + return fmt.Errorf("numbering migration %s: %w", name, err) + } + + var applied int + + err = tx.Raw( + "SELECT count(*) FROM schema_migrations WHERE version = ?", + version, + ).Row().Scan(&applied) + if err != nil { + return fmt.Errorf("checking migration %s: %w", name, err) + } + + if applied > 0 { + return nil + } + + err = run(tx, name) + if err != nil { + return err + } + + err = tx.Exec( + "INSERT INTO schema_migrations (version) VALUES (?)", version, + ).Error + if err != nil { + return fmt.Errorf("recording migration %s: %w", name, err) + } + + return nil +} + +// run executes every statement in the migration file name. +func run(tx *gorm.DB, name string) error { + script, err := files.ReadFile(name) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + err = tx.Exec(string(script)).Error + if err != nil { + return fmt.Errorf("applying migration %s: %w", name, err) + } + + return nil +} diff --git a/internal/db/migrations/migrations_test.go b/internal/db/migrations/migrations_test.go new file mode 100644 index 0000000..51065ef --- /dev/null +++ b/internal/db/migrations/migrations_test.go @@ -0,0 +1,57 @@ +package migrations_test + +import ( + "database/sql" + "log/slog" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + _ "modernc.org/sqlite" + "sneak.berlin/go/webhooker/internal/db/migrations" + "sneak.berlin/go/webhooker/internal/gormlog" +) + +// TestApplyRunsEachFileOnce applies each kind of database file's +// migrations to a new file twice. The first time records every file. +// The second finds them all recorded and runs none, which is the only +// way it can succeed: 001_schema.sql creates tables that then exist. +func TestApplyRunsEachFileOnce(t *testing.T) { + t.Parallel() + + for _, dir := range []string{ + migrations.Main, migrations.Events, migrations.Archive, + } { + t.Run(dir, func(t *testing.T) { + t.Parallel() + + sqlDB, err := sql.Open( + "sqlite", filepath.Join(t.TempDir(), "test.db"), + ) + require.NoError(t, err) + + t.Cleanup(func() { _ = sqlDB.Close() }) + + db, err := gorm.Open( + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{ + Logger: gormlog.New(slog.New(slog.DiscardHandler)), + }, + ) + require.NoError(t, err) + + require.NoError(t, migrations.Apply(db, dir)) + require.NoError(t, migrations.Apply(db, dir)) + + var versions string + + require.NoError(t, sqlDB.QueryRowContext(t.Context(), + "SELECT group_concat(version) FROM schema_migrations", + ).Scan(&versions)) + assert.Equal(t, "0,1", versions) + }) + } +} diff --git a/internal/delivery/archive_sweeper.go b/internal/delivery/archive_sweeper.go index 2cd2934..1a6b87a 100644 --- a/internal/delivery/archive_sweeper.go +++ b/internal/delivery/archive_sweeper.go @@ -8,6 +8,7 @@ import ( "time" "go.uber.org/fx" + "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/lifecycle" @@ -25,14 +26,14 @@ type ArchiveSweeperParams struct { Logger *logger.Logger } -// ArchiveSweeper periodically prunes expired rows from -// per-webhook archive databases whose database target carries a -// positive expiry. +// ArchiveSweeper periodically prunes expired rows from the +// archive databases of database targets that carry a positive +// expiry. // // Without it, pruning happens only when an archive is // (re)opened, and archives are only ever reopened by writes: an -// archive belonging to a webhook that has stopped receiving -// events would keep its expired rows forever. The sweep closes +// archive whose target has stopped receiving events would keep +// its expired rows forever. The sweep closes // that gap without changing anything for archives whose expiry // is unset or "never". // @@ -44,8 +45,13 @@ type ArchiveSweeper struct { eng *Engine log *slog.Logger interval time.Duration - cancel context.CancelFunc - wg sync.WaitGroup + + // cancel needs no lock: fx calls the stop hook only after the + // start hook has returned, so stop never reads it while start + // is still setting it. + cancel context.CancelFunc + + wg sync.WaitGroup } // NewArchiveSweeper creates the archive sweeper and registers @@ -155,17 +161,25 @@ func (s *ArchiveSweeper) run(ctx context.Context) { // soft-deleted along with it, so GORM's default scope already // excludes them. // -// A failure for one webhook is logged and the sweep continues, +// A failure for one target is logged and the sweep continues, // matching how the write path already treats a prune error as // non-fatal. func (s *ArchiveSweeper) sweep(ctx context.Context) { var targets []database.Target err := s.db.DB(). + WithContext(ctx). Model(&database.Target{}). Where("type = ?", database.TargetTypeDatabase). Find(&targets).Error if err != nil { + // The app stopping as a sweep starts cancels the listing. + // Stopping is not a failure, so it must not produce an + // error line. + if ctx.Err() != nil { + return + } + s.log.Error( "archive sweep: failed to list database targets", "error", err, @@ -187,8 +201,7 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) { // sweepTarget prunes the archive of a single database target. // A missing, empty, or "never" expiry parses as a zero duration -// and is skipped entirely, so those archives keep exactly the -// behaviour they had before the sweep existed. +// and is skipped entirely, so those archives are never pruned. func (s *ArchiveSweeper) sweepTarget(target *database.Target) { expiry, err := parseArchiveExpiry(target.Config) if err != nil { @@ -210,19 +223,20 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) { return } - err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry) + err = s.eng.dbTarget.sweepArchive(target.ID, expiry) if err == nil { return } - // A writer evicted underneath the sweep means the operator - // deleted the webhook (or its last database target) while the - // sweep was walking the target list. That is an ordinary + // A writer evicted, or a target row gone, underneath the sweep + // means the operator deleted the target or its webhook while + // the sweep was walking the target list. That is an ordinary // interleaving, not a failure, so it must not produce an // error line. - if errors.Is(err, errArchiveWriterEvicted) { + if errors.Is(err, errArchiveWriterEvicted) || + errors.Is(err, gorm.ErrRecordNotFound) { s.log.Debug( - "archive sweep: writer evicted mid-sweep", + "archive sweep: target deleted mid-sweep", "webhook_id", target.WebhookID, "target_id", target.ID, ) diff --git a/internal/delivery/archive_sweeper_test.go b/internal/delivery/archive_sweeper_test.go index cdfab09..bbd0418 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/delivery/archive_sweeper_test.go @@ -1,9 +1,11 @@ package delivery_test import ( + "bytes" "context" "database/sql" "fmt" + "log/slog" "net/http" "os" "path/filepath" @@ -19,7 +21,10 @@ import ( "gorm.io/gorm/clause" _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/gormlog" ) const ( @@ -34,51 +39,42 @@ const ( sweepConcurrentWrites = 20 ) -// sweeperEnv bundles the pieces an archive sweep test drives: -// a main configuration database holding webhooks and targets, a -// delivery engine owning the archive writer registry, and the -// data directory the archive files live in. -type sweeperEnv struct { +// archiveTestWebhookName is the name of every webhook +// seedDatabaseTarget creates. It is not safe in a file name as it +// stands, so every archive test goes through archiveNamePart. +const archiveTestWebhookName = "Sweep Test!" + +// archiveEnv bundles the pieces an archive test drives: a main +// configuration database holding webhooks and targets, a delivery +// engine owning the archive writer registry, the archive sweeper, +// and the data directory the archive files live in. +type archiveEnv struct { sweeper *delivery.ArchiveSweeper eng *delivery.Engine mainDB *database.Database dataDir string } -func setupSweeperTest(t *testing.T) *sweeperEnv { +func setupArchiveTest(t *testing.T) *archiveEnv { t.Helper() dataDir := t.TempDir() log := archiveTestLogger() - sqlDB, err := sql.Open( - "sqlite", - fmt.Sprintf( - "file:%s?mode=rwc", - filepath.Join(dataDir, "main.db"), - ), - ) + mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler)) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - gdb, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, - ) - require.NoError(t, err) - - mainDB := database.NewTestDatabase(gdb) - require.NoError(t, mainDB.Migrate()) + t.Cleanup(func() { _ = mainDB.Close() }) eng := delivery.NewTestEngineWithDB( mainDB, - database.NewTestWebhookDBManager(dataDir), + databasetest.NewWebhookDBManager(t, dataDir), log, &http.Client{Timeout: 5 * time.Second}, 1, ) - return &sweeperEnv{ + return &archiveEnv{ sweeper: delivery.NewTestArchiveSweeper( mainDB, eng, log, ), @@ -88,25 +84,27 @@ func setupSweeperTest(t *testing.T) *sweeperEnv { } } -// archivePath returns where the engine keeps a webhook's -// archive file. -func (env *sweeperEnv) archivePath(webhookID string) string { +// archivePath returns where the engine keeps a database target's +// archive file, for the names seedDatabaseTarget gave it. +func (env *archiveEnv) archivePath(tgt *database.Target) string { return filepath.Join( - env.dataDir, fmt.Sprintf("archive-%s.db", webhookID), + env.dataDir, + delivery.ArchiveFileName( + archiveTestWebhookName, tgt.Name, tgt.ID, + ), ) } // seedDatabaseTarget creates a webhook with one database target -// carrying the given target config JSON, and returns the -// webhook id. -func (env *sweeperEnv) seedDatabaseTarget( +// carrying the given target config JSON, and returns the target. +func (env *archiveEnv) seedDatabaseTarget( t *testing.T, configJSON string, -) string { +) *database.Target { t.Helper() wh := &database.Webhook{ UserID: uuid.New().String(), - Name: "sweep-test", + Name: archiveTestWebhookName, } require.NoError( t, @@ -115,9 +113,19 @@ func (env *sweeperEnv) seedDatabaseTarget( Create(wh).Error, ) + return env.addDatabaseTarget(t, wh.ID, configJSON) +} + +// addDatabaseTarget creates one more database target on an +// existing webhook and returns it. +func (env *archiveEnv) addDatabaseTarget( + t *testing.T, webhookID, configJSON string, +) *database.Target { + t.Helper() + tgt := &database.Target{ - WebhookID: wh.ID, - Name: "archive", + WebhookID: webhookID, + Name: "Archive", Type: database.TargetTypeDatabase, Active: true, Config: configJSON, @@ -129,19 +137,30 @@ func (env *sweeperEnv) seedDatabaseTarget( Create(tgt).Error, ) - return wh.ID + return tgt } -// seedArchiveRows creates the archive file for a webhook and +// seedArchiveRows creates the archive file for a target and // inserts one row per supplied archived-at timestamp, returning // the archive path. The handle is closed before returning, so // the archive is idle exactly as it would be with no traffic. -func (env *sweeperEnv) seedArchiveRows( - t *testing.T, webhookID string, archivedAt ...time.Time, +func (env *archiveEnv) seedArchiveRows( + t *testing.T, tgt *database.Target, archivedAt ...time.Time, ) string { t.Helper() - path := env.archivePath(webhookID) + path := env.archivePath(tgt) + seedArchiveFile(t, path, tgt.WebhookID, archivedAt...) + + return path +} + +// seedArchiveFile creates the archive file at path and inserts one row +// per supplied archived-at timestamp, as seedArchiveRows does. +func seedArchiveFile( + t *testing.T, path, webhookID string, archivedAt ...time.Time, +) { + t.Helper() sqlDB, err := sql.Open( "sqlite", fmt.Sprintf("file:%s?mode=rwc", path), @@ -149,13 +168,12 @@ func (env *sweeperEnv) seedArchiveRows( require.NoError(t, err) gdb, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, ) require.NoError(t, err) - require.NoError( - t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}), - ) + require.NoError(t, migrations.Apply(gdb, migrations.Archive)) for i, at := range archivedAt { row := delivery.ExportArchivedEvent{ @@ -169,8 +187,6 @@ func (env *sweeperEnv) seedArchiveRows( } require.NoError(t, sqlDB.Close()) - - return path } // archivedEventIDs returns the event ids currently stored in an @@ -208,7 +224,8 @@ func countArchivedRows(path string) (int64, error) { defer func() { _ = sqlDB.Close() }() gdb, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, ) if err != nil { return 0, err @@ -243,13 +260,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) now := time.Now() path := env.seedArchiveRows( - t, webhookID, + t, tgt, now.Add(-48*time.Hour), now.Add(-time.Minute), ) @@ -287,60 +304,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext( } // TestArchiveSweep_DoesNotResurrectEvictedWriter covers the -// interleaving where a sweep tick has already listed a webhook's -// target when the webhook is deleted and its writer evicted. The -// sweep must not put a writer back into the registry: nothing -// would ever evict it again, which is precisely the leak this -// change exists to close. +// interleaving where a sweep tick has already listed a target +// when the target is deleted and its writer evicted. The sweep +// must not put a writer back into the registry: nothing would +// ever evict it again, which is precisely the leak this change +// exists to close. func TestArchiveSweep_DoesNotResurrectEvictedWriter( t *testing.T, ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) // Prime the registry the way a delivery would, then evict as // the deletion path does. The target row is deliberately left - // in place: this is the tick that listed the webhook before + // in place: this is the tick that listed the target before // the deletion committed. - _, err := env.eng.ExportEnsureArchiveWriter(webhookID) + _, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) require.NoError(t, err) - env.eng.EvictWebhook(webhookID) - require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) + env.eng.EvictTarget(tgt.ID) + require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) env.sweeper.ExportSweep(context.Background()) assert.False( - t, env.eng.ExportHasArchiveWriter(webhookID), - "a sweep must never re-register a writer for a webhook "+ + t, env.eng.ExportHasArchiveWriter(tgt.ID), + "a sweep must never re-register a writer for a target "+ "whose registry entry has already been released", ) } // TestArchiveSweep_LeavesNoRegistryEntry states the same // invariant in its general form: sweeping an archive whose -// webhook has no cached writer must not leave one behind, so the +// target has no cached writer must not leave one behind, so the // registry keeps holding only writers a delivery created and an // eviction can reach. func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) path := env.seedArchiveRows( - t, webhookID, + t, tgt, time.Now().Add(-48*time.Hour), time.Now().Add(-time.Minute), ) - require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) + require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) env.sweeper.ExportSweep(context.Background()) @@ -349,7 +366,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { "the sweep must still prune an idle archive", ) assert.False( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "the sweep must release the registry entry it created", ) } @@ -364,34 +381,31 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"n":1}`) - event.WebhookID = webhookID - d := seedDatabaseTargetDelivery( - t, webhookDB, event, `{"expiry":"1h"}`, - ) + d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) env.sweeper.ExportSweep(context.Background()) - require.False(t, env.eng.ExportHasArchiveWriter(webhookID)) + require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) env.eng.ExportDeliverDatabase(webhookDB, d) assert.True( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "a delivery's writer must stay registered", ) env.sweeper.ExportSweep(context.Background()) assert.True( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "a sweep must not drop a writer a delivery owns", ) } @@ -423,15 +437,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) sweepWriter, created, err := env.eng.ExportSweepWriterFor( - webhookID, + tgt.ID, ) require.NoError(t, err) require.True( @@ -442,37 +456,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( // The delivery lands mid-sweep and adopts the entry. webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"n":1}`) - event.WebhookID = webhookID - d := seedDatabaseTargetDelivery( - t, webhookDB, event, `{"expiry":"1h"}`, - ) + d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) env.eng.ExportDeliverDatabase(webhookDB, d) - adopted := env.eng.ExportArchiveWriterFor(webhookID) + adopted := env.eng.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, adopted) require.True( t, sweepWriter.Same(adopted), "the delivery must have adopted the sweep's writer", ) require.True( - t, env.eng.ExportArchiveHandleOpen(webhookID), + t, env.eng.ExportArchiveHandleOpen(tgt.ID), "the delivery leaves the archive handle open", ) // The sweep finishes. - env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter) + env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter) require.True( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "a writer adopted by a delivery during a sweep must "+ "stay registered, or its open handle is unreachable", ) - env.eng.EvictWebhook(webhookID) + env.eng.EvictTarget(tgt.ID) assert.False( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "the adopted writer must still be evictable", ) assert.False( @@ -481,34 +492,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( ) } -// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a -// failure for one webhook does not abort the sweep for the +// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a +// failure for one target does not abort the sweep for the // others: an unparseable expiry and an unreadable archive both // have to be logged and stepped over. -func TestArchiveSweep_ContinuesAfterPerWebhookFailure( +func TestArchiveSweep_ContinuesAfterPerTargetFailure( t *testing.T, ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) // Seeded first so the sweep reaches them before the healthy - // webhook: targets come back in insertion order. - badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) + // target: targets come back in insertion order. + badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) env.seedArchiveRows( - t, badConfigID, time.Now().Add(-48*time.Hour), + t, badConfig, time.Now().Add(-48*time.Hour), ) - corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) require.NoError(t, os.WriteFile( - env.archivePath(corruptID), + env.archivePath(corrupt), []byte("this is not a sqlite database"), 0o600, )) - healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) healthyPath := env.seedArchiveRows( - t, healthyID, + t, healthy, time.Now().Add(-48*time.Hour), time.Now().Add(-time.Minute), ) @@ -518,14 +529,14 @@ func TestArchiveSweep_ContinuesAfterPerWebhookFailure( assert.Equal( t, []string{sweepRowNew}, archivedEventIDs(t, healthyPath), - "a failure for an earlier webhook must not stop the "+ + "a failure for an earlier target must not stop the "+ "sweep from pruning the ones after it", ) } // TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second // of the two no-create guards. The first is the stat in -// sweepWebhook; this one is the SQLite open mode, which is what +// sweepExpired; this one is the SQLite open mode, which is what // protects the window between that stat and the open. Flipping // the sweep's mode to create-if-missing makes this fail. func TestArchiveSweep_OpenExistingDoesNotCreateFile( @@ -561,13 +572,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile( func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) now := time.Now() path := env.seedArchiveRows( - t, webhookID, + t, tgt, now.Add(-48*time.Hour), now.Add(-time.Minute), ) @@ -600,11 +611,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) path := env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) w := delivery.NewExportArchiveWriter( @@ -640,39 +651,94 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"n":1}`) - event.WebhookID = webhookID - d := seedDatabaseTargetDelivery( - t, webhookDB, event, `{"expiry":"1h"}`, - ) + d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) env.eng.ExportDeliverDatabase(webhookDB, d) require.True( - t, env.eng.ExportArchiveHandleOpen(webhookID), + t, env.eng.ExportArchiveHandleOpen(tgt.ID), "the delivery must leave the archive handle open", ) env.sweeper.ExportSweep(context.Background()) require.True( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "the delivery's registry entry must survive the sweep", ) assert.False( - t, env.eng.ExportArchiveHandleOpen(webhookID), + t, env.eng.ExportArchiveHandleOpen(tgt.ID), "the sweep must leave the archive closed", ) } +// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep +// closes the handle it finds open before it reopens the file. +// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the +// close, the reopen replaces the handle without closing it, the +// sweep then closes only the new one, and one connection leaks per +// archive per sweep. +func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive.db") + + w := delivery.NewExportArchiveWriter( + path, archiveTestLogger(), 0, + ) + + require.NoError(t, w.Open(time.Hour)) + + before, err := w.DB().DB() + require.NoError(t, err) + + require.NoError(t, w.SweepExpired(time.Hour)) + + assert.Error( + t, before.PingContext(t.Context()), + "the handle open before the sweep must be closed by it", + ) +} + +// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose +// context is already cancelled, as when the app stops just as a +// sweep starts, returns without an error line: stopping is not a +// failure. +func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + + var errorLines bytes.Buffer + + sweeper := delivery.NewTestArchiveSweeper( + env.mainDB, env.eng, + slog.New(slog.NewTextHandler( + &errorLines, + &slog.HandlerOptions{Level: slog.LevelError}, + )), + ) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + sweeper.ExportSweep(ctx) + + assert.Empty( + t, errorLines.String(), + "a cancelled sweep must not log at error level", + ) +} + // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a // no-op for the default retention policy, so archives with no // expiry (or the literal "never") behave exactly as before. @@ -684,11 +750,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { `{"expiry":""}`, "", } { - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, configJSON) + tgt := env.seedDatabaseTarget(t, configJSON) path := env.seedArchiveRows( - t, webhookID, + t, tgt, time.Now().Add(-10000*time.Hour), ) @@ -699,7 +765,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { "config %q must keep rows forever", configJSON, ) assert.False( - t, env.eng.ExportHasArchiveWriter(webhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "config %q must leave no registry entry behind", configJSON, ) @@ -714,18 +780,18 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { // // The spec is stronger than that — a "never" archive is skipped // before any file is touched — so the archive here exists but has -// never been migrated. Opening it at all would run AutoMigrate -// and create the archive table, which is exactly what must not -// happen. +// never been migrated. Opening it at all would apply the archive +// schema and create the archive table, which is exactly what must +// not happen. func TestArchiveSweep_NeverExpirySkipsBeforeOpening( t *testing.T, ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`) - path := env.archivePath(webhookID) + tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`) + path := env.archivePath(tgt) seedUnmigratedArchive(t, path) require.False(t, archiveTableExists(t, path)) @@ -740,7 +806,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening( // seedUnmigratedArchive creates an archive file that exists but // carries no archive schema, so any open of it is observable: the -// archive table appears only if something ran AutoMigrate. +// archive table appears only if something applied the schema. func seedUnmigratedArchive(t *testing.T, path string) { t.Helper() @@ -768,16 +834,16 @@ func archiveTableExists(t *testing.T, path string) bool { } // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep -// never conjures an archive: a webhook with a database target -// that has never received an event must still have no archive -// file (nor SQLite sidecar) after a sweep. +// never conjures an archive: a database target that has never +// received an event must still have no archive file (nor SQLite +// sidecar) after a sweep, and no registry entry either. func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) - path := env.archivePath(webhookID) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + path := env.archivePath(tgt) require.NoFileExists(t, path) @@ -789,6 +855,11 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { "the sweep must not create an archive file", ) } + + assert.False( + t, env.eng.ExportHasArchiveWriter(tgt.ID), + "the sweep must leave no registry entry behind", + ) } // TestArchiveSweep_DoesNotCreateAfterWriterExists covers the @@ -800,11 +871,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) - path, err := env.eng.ExportEnsureArchiveWriter(webhookID) + path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) require.NoError(t, err) require.NoFileExists(t, path) @@ -819,17 +890,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists( func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) path := env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) require.NoError( t, env.mainDB.DB(). - Where("webhook_id = ?", webhookID). + Where("webhook_id = ?", tgt.WebhookID). Delete(&database.Target{}).Error, ) @@ -842,14 +913,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { } // TestArchiveSweep_ConcurrentWrites proves the sweep serialises -// against writes through the per-webhook writer mutex. Run -// under -race, an unsynchronised sweep would be caught here. +// against writes through the target's writer mutex. Run under +// -race, an unsynchronised sweep would be caught here. func TestArchiveSweep_ConcurrentWrites(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookDB := testWebhookDB(t) @@ -862,13 +933,10 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { for range sweepConcurrentWrites { event := seedEvent(t, webhookDB, `{"n":1}`) - event.WebhookID = webhookID deliveries = append( deliveries, - seedDatabaseTargetDelivery( - t, webhookDB, event, `{"expiry":"1h"}`, - ), + seedDatabaseTargetDelivery(t, webhookDB, event, tgt), ) } @@ -894,7 +962,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { wg.Wait() - assert.FileExists(t, env.archivePath(webhookID)) + assert.FileExists(t, env.archivePath(tgt)) } // TestArchiveSweeper_StopsCleanly proves the background loop @@ -902,11 +970,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { func TestArchiveSweeper_StopsCleanly(t *testing.T) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) - webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) env.seedArchiveRows( - t, webhookID, time.Now().Add(-48*time.Hour), + t, tgt, time.Now().Add(-48*time.Hour), ) env.sweeper.ExportSetInterval(time.Millisecond) @@ -930,7 +998,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout( ) { t.Parallel() - env := setupSweeperTest(t) + env := setupArchiveTest(t) lc := &recordingLifecycle{} env.sweeper.ExportRegisterHooks(lc) diff --git a/internal/delivery/circuit_breaker.go b/internal/delivery/circuit_breaker.go index afb77d4..f0ae8eb 100644 --- a/internal/delivery/circuit_breaker.go +++ b/internal/delivery/circuit_breaker.go @@ -102,6 +102,20 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration { return remaining } +// StateAndCooldown returns the circuit state and, while the circuit is +// open, what is left of the cooldown, or zero once that has passed. +// Both are read under one lock, so they always agree. +func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.state != CircuitOpen { + return cb.state, 0 + } + + return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0) +} + // RecordSuccess records a successful delivery and resets // the circuit breaker to closed state. func (cb *CircuitBreaker) RecordSuccess() { diff --git a/internal/delivery/delivery_totals_test.go b/internal/delivery/delivery_totals_test.go new file mode 100644 index 0000000..ffa98ef --- /dev/null +++ b/internal/delivery/delivery_totals_test.go @@ -0,0 +1,188 @@ +package delivery_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// targetTotals reads one target's totals from a webhook database, all +// zero when it has no row. +func targetTotals( + t *testing.T, db *gorm.DB, targetID string, +) database.TargetTotals { + t.Helper() + + var rows []database.TargetTotals + + require.NoError(t, db.Where("target_id = ?", targetID). + Find(&rows).Error) + + if len(rows) == 0 { + return database.TargetTotals{TargetID: targetID} + } + + return rows[0] +} + +// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status +// write records for the webhook page's statistics: the time a delivery +// finished, set only when it becomes delivered or failed and stored in +// the same form as created_at, and one more on its target's delivered +// or failed total. +func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) { + t.Parallel() + + tests := []struct { + status database.DeliveryStatus + finished bool + delivered int64 + failed int64 + }{ + {database.DeliveryStatusRetrying, false, 0, 0}, + {database.DeliveryStatusDelivered, true, 1, 0}, + {database.DeliveryStatusFailed, true, 0, 1}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + t.Parallel() + + db := testWebhookDB(t) + e := testEngine(t, 1) + event := seedEvent(t, db, `{}`) + targetID := uuid.New().String() + d := seedDelivery( + t, db, event.ID, targetID, + database.DeliveryStatusPending, + ) + + before := time.Now() + + require.NoError(t, e.ExportUpdateDeliveryStatus( + db, &d, tt.status, + )) + + var stored database.Delivery + + require.NoError(t, db.First(&stored, "id = ?", d.ID).Error) + assert.Equal(t, tt.status, stored.Status) + + if tt.finished { + require.NotNil(t, stored.FinishedAt) + assert.False(t, stored.FinishedAt.Before(before)) + + var createdAt, finishedAt string + + require.NoError(t, db.Raw( + `SELECT CAST(created_at AS TEXT), + CAST(finished_at AS TEXT) + FROM deliveries WHERE id = ?`, d.ID, + ).Row().Scan(&createdAt, &finishedAt)) + assertStoredTime(t, createdAt) + assertStoredTime(t, finishedAt) + } else { + assert.Nil(t, stored.FinishedAt) + } + + assert.Equal(t, database.TargetTotals{ + TargetID: targetID, + Delivered: tt.delivered, + Failed: tt.failed, + }, targetTotals(t, db, targetID)) + }) + } +} + +// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a +// delivery retention deleted while the engine still held it. Failing +// it afterwards writes no row, so it adds no failure either: retention +// has already counted what it removed. +func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) { + t.Parallel() + + db := testWebhookDB(t) + e := testEngine(t, 1) + event := seedEvent(t, db, `{}`) + targetID := uuid.New().String() + d := seedDelivery( + t, db, event.ID, targetID, + database.DeliveryStatusRetrying, + ) + + require.NoError(t, db.Unscoped(). + Delete(&database.Delivery{}, "id = ?", d.ID).Error) + + require.NoError(t, e.ExportUpdateDeliveryStatus( + db, &d, database.DeliveryStatusFailed, + )) + + assert.Equal(t, database.TargetTotals{TargetID: targetID}, + targetTotals(t, db, targetID)) +} + +// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a +// delivery settled a second time, as recovery can do when a worker has +// settled it since recovery read it. Neither status writes over the +// first, and the totals do not move. +func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain( + t *testing.T, +) { + t.Parallel() + + finished := []database.DeliveryStatus{ + database.DeliveryStatusDelivered, + database.DeliveryStatusFailed, + } + + for _, first := range finished { + t.Run(string(first), func(t *testing.T) { + t.Parallel() + + db := testWebhookDB(t) + e := testEngine(t, 1) + event := seedEvent(t, db, `{}`) + targetID := uuid.New().String() + d := seedDelivery( + t, db, event.ID, targetID, + database.DeliveryStatusRetrying, + ) + + // The delivery as recovery read it, before the worker + // settled it. + readBefore := d + + require.NoError(t, e.ExportUpdateDeliveryStatus( + db, &d, first, + )) + + var settled database.Delivery + + require.NoError(t, db.First(&settled, "id = ?", d.ID).Error) + require.NotNil(t, settled.FinishedAt) + + totals := targetTotals(t, db, targetID) + + for _, again := range finished { + stale := readBefore + + require.NoError(t, e.ExportUpdateDeliveryStatus( + db, &stale, again, + )) + } + + var stored database.Delivery + + require.NoError(t, db.First(&stored, "id = ?", d.ID).Error) + assert.Equal(t, first, stored.Status) + require.NotNil(t, stored.FinishedAt) + assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt)) + assert.Equal(t, totals, targetTotals(t, db, targetID)) + }) + } +} diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index 4786f08..be78e66 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -14,6 +14,7 @@ import ( "go.uber.org/fx" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/metrics" @@ -109,6 +110,7 @@ type Task struct { MaxRetries int Method string + RawQuery string Headers string ContentType string Body *string @@ -122,21 +124,33 @@ type Notifier interface { Notify(tasks []Task) } -// WebhookEvictor releases the delivery engine's per-webhook -// state for a webhook that no longer needs it — currently the -// cached archive writer of the database target, whose open -// file handle would otherwise outlive the webhook. +// Archives is how the handlers keep the database targets' archive +// files in step with the configuration. Deleting a webhook or a +// target releases the cached archive writers, whose open file +// handles would otherwise outlive them; renaming one renames the +// archive files, which are named for the webhook and the target +// (see ArchiveFileName). // -// It is deliberately separate from Notifier and deliberately -// one method wide: archiving lifecycle is not notification, and -// a single-method interface keeps the handlers package free of -// any dependency on the engine's internals while staying -// trivially fakeable in tests. +// It is deliberately separate from Notifier: archiving lifecycle +// is not notification, and a small interface keeps the handlers +// package free of any dependency on the engine's internals while +// staying trivially fakeable in tests. // -// EvictWebhook never deletes an archive file. It is idempotent -// and is a no-op for a webhook with no engine state. -type WebhookEvictor interface { +// Neither eviction deletes an archive file. Both are idempotent +// and are no-ops for a webhook or target with no engine state. +type Archives interface { EvictWebhook(webhookID string) + EvictTarget(targetID string) + Rename(targetID, webhookName, targetName string) error +} + +// CircuitBreakers is how the handlers read a target's circuit +// breaker, so the webhook page and the event log can say that +// deliveries to the target are paused and until when. Like Archives, +// it keeps the handlers free of the engine's internals and is +// trivially faked in tests. +type CircuitBreakers interface { + StateAndCooldown(targetID string) (CircuitState, time.Duration) } // EngineParams are the fx dependencies for the delivery @@ -146,8 +160,10 @@ type EngineParams struct { DB *database.Database DBManager *database.WebhookDBManager + Globals *globals.Globals Logger *logger.Logger SSRFGuard *Guard + Metrics *metrics.Set } // Engine processes queued deliveries in the background @@ -167,21 +183,27 @@ type Engine struct { retryCh chan Task workers int - // mtr is the delivery metric set. Production wires the - // process-wide one; a test can substitute a set registered on - // a private registry so its assertions are not disturbed by - // deliveries other tests are making at the same time. + // version is the running build's version, the one the web UI + // footer shows. userAgent puts it on every outbound request. + version string + + // mtr is the delivery metric set. Production wires the one + // registered on the registry /metrics serves; a test can + // substitute a set registered on a registry it holds, so it can + // gather what its own deliveries recorded. mtr *metrics.Set // targets maps each target type to its implementation. targets map[database.TargetType]Target - // httpTarget is retained so tests can reach the HTTP - // target's shared client and circuit breakers. - httpTarget *httpTarget + // httpTarget and slackTarget are retained so StateAndCooldown + // can read their circuit breakers, and so tests can reach the + // HTTP target's shared client. + httpTarget *httpTarget + slackTarget *slackTarget // dbTarget is retained so the engine can reach the archive - // writer registry for webhook eviction and the idle sweep. + // writer registry for eviction, renames and the idle sweep. dbTarget *databaseTarget // inflight is the set of deliveries this engine currently owns. @@ -204,7 +226,8 @@ func New( deliveryCh: make(chan Task, deliveryChannelSize), retryCh: make(chan Task, retryChannelSize), workers: defaultWorkers, - mtr: metrics.Default(), + version: params.Globals.Version, + mtr: params.Metrics, } e.initTargets(&http.Client{ @@ -249,17 +272,67 @@ func (e *Engine) Notify(tasks []Task) { } } -// EvictWebhook implements WebhookEvictor. It releases the -// engine's per-webhook archiving state: the database target's -// cached archive writer is dropped from the registry and its -// file handle closed. The archive file itself is left on disk -// — it is long-term storage the operator owns. +// EvictWebhook implements Archives. The cached archive writer of +// every database target of the webhook is dropped from the +// registry and its file handle closed. The archive files +// themselves are left on disk — they are long-term storage the +// operator owns. func (e *Engine) EvictWebhook(webhookID string) { if e.dbTarget == nil { return } - e.dbTarget.evict(webhookID) + e.dbTarget.evictWebhook(webhookID) +} + +// EvictTarget implements Archives. It is EvictWebhook for a single +// database target, and leaves the archive file on disk the same +// way. +func (e *Engine) EvictTarget(targetID string) { + if e.dbTarget == nil { + return + } + + e.dbTarget.evict(targetID) +} + +// Rename implements Archives. It renames every one of a database +// target's archive files to ArchiveFileName(webhookName, targetName, +// targetID), each keeping the period in its name, under the lock the +// target's archive writes and the idle sweep take. It never replaces +// a file: if one already has a new name, the error is +// ErrArchiveNameTaken. The caller renames before it saves the new +// name: see databaseTarget.rename. +func (e *Engine) Rename( + targetID, webhookName, targetName string, +) error { + if e.dbTarget == nil { + return nil + } + + return e.dbTarget.rename(targetID, webhookName, targetName) +} + +// StateAndCooldown implements CircuitBreakers. It returns the state of +// the target's circuit breaker and, while the breaker is open, what is +// left of its cooldown; the cooldown is zero once that has passed and +// in any other state. A target with no breaker reads as closed with no +// cooldown, and reading never creates one. +func (e *Engine) StateAndCooldown( + targetID string, +) (CircuitState, time.Duration) { + for _, core := range []*httpCore{ + e.httpTarget.httpCore, e.slackTarget.httpCore, + } { + val, ok := core.circuitBreakers.Load(targetID) + if ok { + cb, _ := val.(*CircuitBreaker) + + return cb.StateAndCooldown() + } + } + + return CircuitClosed, 0 } // ScheduleRetry schedules a task to be re-enqueued onto the @@ -300,6 +373,13 @@ func (e *Engine) ScheduleRetry( }) } +// userAgent is the User-Agent header of every http and slack +// delivery request: the program name and the running build's +// version. +func (e *Engine) userAgent() string { + return "webhooker/" + e.version +} + // registerHooks wires the engine's start and stop into the fx // lifecycle. The start hook's context is deliberately ignored // (see start for why the worker pool must not inherit it); the @@ -366,7 +446,8 @@ func (e *Engine) start() { // Once the pool has drained it closes the archive writers, so a // clean stop leaves no archive -wal behind. Nothing else holds a // writer for long by then: the archive sweeper stops before the -// engine, and deleting a webhook only closes one. If the pool did +// engine, and deleting or renaming a webhook or target only closes +// or moves one. If the pool did // not drain in time, the writers are left open, as a kill would // leave them. Closing them would wait for any write in progress, // and a worker still running would then open new writers that @@ -531,6 +612,11 @@ func (e *Engine) processRetryTask( return } + // Set before anything below can fail the delivery: the failure is + // added to this target's totals. + d.EventID = task.EventID + d.TargetID = task.TargetID + if d.Status != database.DeliveryStatusRetrying { e.log.Debug( "skipping retry for delivery "+ @@ -562,8 +648,6 @@ func (e *Engine) processRetryTask( } target := buildTargetFromTask(task) - d.EventID = task.EventID - d.TargetID = task.TargetID d.Event = event d.Target = target @@ -650,10 +734,9 @@ func (e *Engine) recoverInFlight(ctx context.Context) { default: } - if !e.dbManager.DBExists(webhookID) { - continue - } - + // Opened even when its file is missing, so that a lost + // database is reported at start, not when the webhook next + // receives an event, which for a quiet webhook may be never. e.recoverWebhookDeliveries(ctx, webhookID) } } @@ -661,7 +744,24 @@ func (e *Engine) recoverInFlight(ctx context.Context) { func (e *Engine) recoverWebhookDeliveries( ctx context.Context, webhookID string, ) { - webhookDB, err := e.dbManager.GetDB(webhookID) + // The web interface is already serving, so the webhook may have + // been deleted since the list was read. Opening its database then + // would create the file again after the delete removed it. + stillExists := func() (bool, error) { + var count int64 + + err := e.database.DB(). + Model(&database.Webhook{}). + Where("id = ?", webhookID). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("confirming webhook exists: %w", err) + } + + return count > 0, nil + } + + webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists) if err != nil { e.log.Error( "failed to get webhook database for recovery", @@ -672,6 +772,10 @@ func (e *Engine) recoverWebhookDeliveries( return } + if webhookDB == nil { + return + } + e.recoverPendingDeliveries( ctx, webhookDB, webhookID, ) @@ -1554,8 +1658,9 @@ func (e *Engine) updateDeliveryStatus( targetType database.TargetType, status database.DeliveryStatus, ) error { - err := webhookDB.Model(d). - Update("status", status).Error + err := webhookDB.Transaction(func(tx *gorm.DB) error { + return writeDeliveryStatus(tx, d, status) + }) if err != nil { return fmt.Errorf( "updating delivery %s to status %s: %w", @@ -1574,6 +1679,46 @@ func (e *Engine) updateDeliveryStatus( return nil } +// writeDeliveryStatus writes a delivery's new status. A delivery that +// becomes delivered or failed also gets the time it finished, and is +// added to its target's delivered or failed total. That write changes +// only a delivery not yet delivered or failed, and the total moves +// only when it changed a row: retention may have deleted the delivery +// while the engine was working on it, and a recovery path may settle +// a delivery that a worker has already settled. +func writeDeliveryStatus( + tx *gorm.DB, + d *database.Delivery, + status database.DeliveryStatus, +) error { + if !status.Terminal() { + return tx.Model(d).Update("status", status).Error + } + + res := tx.Model(d). + Where("status NOT IN ?", []database.DeliveryStatus{ + database.DeliveryStatusDelivered, + database.DeliveryStatusFailed, + }). + Updates(map[string]any{ + "status": status, + // GORM's clock, the one that stamps created_at. A bare + // time.Now() carries a monotonic clock reading, which the + // driver would store as an " m=+…" suffix. + "finished_at": tx.NowFunc(), + }) + if res.Error != nil || res.RowsAffected == 0 { + return res.Error + } + + add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1} + if status == database.DeliveryStatusFailed { + add = database.TargetTotals{TargetID: d.TargetID, Failed: 1} + } + + return database.AddTargetTotals(tx, add) +} + // settleStatus moves a delivery to its outcome status and reports a // failed write through bookkeepingFailed, which leaves the row // recoverable. It exists so the target call sites read as one @@ -1611,6 +1756,7 @@ func buildEventFromTask(task *Task) database.Event { event := database.Event{ EntrypointID: task.EntrypointID, Method: task.Method, + RawQuery: task.RawQuery, Headers: task.Headers, ContentType: task.ContentType, } @@ -1763,12 +1909,13 @@ func (e *Engine) takeForRedispatch( return false } + // GORM's clock, as writeDeliveryStatus explains. res := webhookDB. Model(&database.Delivery{}). Where( "id = ? AND status = ?", deliveryID, eligible, ). - UpdateColumn("updated_at", time.Now()) + UpdateColumn("updated_at", webhookDB.NowFunc()) if res.Error != nil { e.log.Error( @@ -1961,6 +2108,7 @@ func buildRecoveryTask( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: bodyPtr, diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 90d028e..2812281 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -1,6 +1,7 @@ package delivery_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -9,7 +10,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "sync/atomic" "testing" @@ -18,10 +18,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -43,12 +43,12 @@ func newISetup(t *testing.T) iSetup { wDB := iSeedWebhookDB(t, dbMgr, wID) return iSetup{ - MainDB: mainDB, + MainDB: mainDB.DB(), DBMgr: dbMgr, WebhookID: wID, WebhookDB: wDB, Engine: delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), + mainDB, dbMgr, slog.New(slog.NewTextHandler( os.Stderr, @@ -62,34 +62,16 @@ func newISetup(t *testing.T) iSetup { } } -func iMainDB(t *testing.T) *gorm.DB { +// iMainDB opens a main database through database.Open, the way the +// service opens it, so these tests cannot pass against journal and +// locking settings production does not use. +func iMainDB(t *testing.T) *database.Database { t.Helper() - dbPath := filepath.Join( - t.TempDir(), "main-test.db", - ) - - // Opened the way the service opens the main database, so these - // tests cannot pass against journal and locking settings - // production does not use. - sqlDB, err := database.OpenSQLite( - dbPath, database.SQLiteModeCreate, - ) + db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler)) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Webhook{}, - &database.Target{}, - &database.User{}, - &database.Setting{}, - )) + t.Cleanup(func() { _ = db.Close() }) return db } @@ -99,7 +81,7 @@ func iDBManager( ) *database.WebhookDBManager { t.Helper() - return database.NewTestWebhookDBManager(t.TempDir()) + return databasetest.NewWebhookDBManager(t, t.TempDir()) } func iSeedWebhookDB( @@ -355,9 +337,14 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) { s := newISetup(t) + var receivedBody string + ts := httptest.NewServer( http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { + func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.WriteHeader(http.StatusOK) }, ), @@ -397,6 +384,8 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) { context.TODO(), &task, ) + assert.Equal(t, event.Body, receivedBody) + iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered, ) @@ -443,9 +432,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB( s := newISetup(t) + var receivedBody string + ts := httptest.NewServer( http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { + func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.WriteHeader(http.StatusOK) }, ), @@ -482,6 +476,8 @@ func TestProcessRetryTask_LargeBody_FetchFromDB( context.TODO(), &task, ) + assert.Equal(t, largeBody, receivedBody) + iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered, ) @@ -656,6 +652,11 @@ func TestRecoverPendingDeliveries(t *testing.T) { t, s.WebhookDB, s.WebhookID, targetID, 3, ) + // A recovered delivery still carries its event's query string. + require.NoError(t, s.WebhookDB.Model(&database.Event{}). + Where("webhook_id = ?", s.WebhookID). + Update("raw_query", eventQuery).Error) + s.Engine.ExportRecoverPendingDeliveries( context.Background(), s.WebhookDB, s.WebhookID, @@ -670,6 +671,8 @@ func TestRecoverPendingDeliveries(t *testing.T) { database.TargetTypeLog, task.TargetType, ) + + assert.Equal(t, eventQuery, task.RawQuery) case <-time.After(2 * time.Second): t.Fatalf("expected task %d", i) } @@ -960,29 +963,12 @@ func TestFailUnretryableRetry_WritesNoTargetRow( database.DeliveryStatusFailed, ) - // The table exists in the per-webhook database because GORM - // migrates the Delivery relation's model alongside it. It must - // stay empty. - var targetRows int64 - - require.NoError(t, s.WebhookDB. - Table("targets"). - Count(&targetRows).Error) - - assert.Zero(t, targetRows, + // The per-webhook database has no targets table, so writing a + // target row there would have failed the status write above. + assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), "orphaned-retry terminal failure wrote a target row "+ "into the per-webhook event database", ) - - var configs []string - - require.NoError(t, s.WebhookDB. - Table("targets"). - Pluck("config", &configs).Error) - - assert.NotContains( - t, strings.Join(configs, " "), hookURL, - ) } func TestRecoverSingleRetry_UnknownTargetType( @@ -1120,6 +1106,85 @@ func TestRecoverInFlight_WithPendingDeliveries( } } +// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook +// whose database file is gone, after a partial restore say. Restart +// recovery opens every webhook's database, so the empty one made in its +// place is reported at start, naming the file +// (https://git.eeqj.de/sneak/webhooker/issues/290). +func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { + t.Parallel() + + mainDB := iMainDB(t) + webhookID := uuid.New().String() + iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database") + + var logs bytes.Buffer + + dbMgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), + ) + t.Cleanup(func() { _ = dbMgr.CloseAll() }) + + engine := delivery.NewTestEngineWithDB( + mainDB, dbMgr, + slog.New(slog.DiscardHandler), + &http.Client{Timeout: 5 * time.Second}, 1, + ) + + engine.ExportRecoverInFlight(context.Background()) + + assert.Contains( + t, logs.String(), + `level=WARN msg="created a new, empty database" webhook_id=`+ + webhookID+" path="+dbMgr.DBPath(webhookID), + ) +} + +// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a +// webhook deleted from the web interface while restart recovery runs. +// Its database file is gone, and recovery must not create it again. +func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( + t *testing.T, +) { + t.Parallel() + + mainDB := iMainDB(t) + webhookID := uuid.New().String() + iCreateWebhook(t, mainDB.DB(), webhookID, "deleted-during-recovery") + + // The first query to return is recovery's read of the list of + // webhooks. Deleting the webhook right after it puts the delete + // between that read and the opening of the webhook's database. + deleted := false + + require.NoError(t, mainDB.DB().Callback().Query().After("gorm:query"). + Register("delete-after-list", func(*gorm.DB) { + if deleted { + return + } + + deleted = true + + require.NoError(t, mainDB.DB().Delete( + &database.Webhook{}, "id = ?", webhookID, + ).Error) + })) + + dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir()) + t.Cleanup(func() { _ = dbMgr.CloseAll() }) + + engine := delivery.NewTestEngineWithDB( + mainDB, dbMgr, + slog.New(slog.DiscardHandler), + &http.Client{Timeout: 5 * time.Second}, 1, + ) + + engine.ExportRecoverInFlight(context.Background()) + + require.True(t, deleted) + assert.False(t, dbMgr.DBExists(webhookID)) +} + // --- HTTP Config with custom headers --- func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { @@ -1411,6 +1476,32 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) { ) } +// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is +// failed for an invalid config only once the reason is recorded. +// Unrecorded, it stays pending, where the sweep finds it again. +func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) { + t.Parallel() + + db := testWebhookDB(t) + e := testEngine(t, 1) + + event, del := iSeedEventAndDelivery( + t, db, `{"config":"invalid"}`, "", + ) + + task, d := iHTTPTaskAndDelivery( + event, del, "bad-config", `not-json`, 0, 1, + ) + + require.NoError(t, db.Exec("drop table delivery_results").Error) + + e.ExportDeliverHTTP(context.TODO(), db, d, task) + + iAssertStatus(t, db, del.ID, + database.DeliveryStatusPending, + ) +} + // --- Notify batching --- func TestNotify_MultipleTasks(t *testing.T) { diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 9fadf9d..69e0d43 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -2,7 +2,6 @@ package delivery_test import ( "context" - "fmt" "path/filepath" "testing" "time" @@ -10,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/fx" + "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -272,22 +272,35 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) { requireStopHookExpires(t, lc.hooks[0], "delivery engine") } -// deliverToArchive runs one delivery to a database target through -// the running engine and returns the webhook's archive file path. -// The archive writer holds the file open afterwards. -func deliverToArchive(t *testing.T, s iSetup) string { +// deliverToArchive gives the setup's webhook a database target, +// runs one delivery to it through the running engine, and returns +// the target's ID and archive file path. The archive writer holds +// the file open afterwards. +func deliverToArchive(t *testing.T, s iSetup) (string, string) { t.Helper() + iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") + + tgt := &database.Target{ + WebhookID: s.WebhookID, + Name: "archive", + Type: database.TargetTypeDatabase, + } + require.NoError( + t, s.MainDB.Omit(clause.Associations).Create(tgt).Error, + ) + deliveryID, task := seedLogTask(t, s) + task.TargetID = tgt.ID task.TargetType = database.TargetTypeDatabase s.Engine.Notify([]delivery.Task{task}) iWaitForDelivered(t, s.WebhookDB, deliveryID) - return filepath.Join( + return tgt.ID, filepath.Join( filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), - fmt.Sprintf("archive-%s.db", s.WebhookID), + "archive-hook-archive-"+tgt.ID+".db", ) } @@ -304,7 +317,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) { lc := startEngineViaHook(t, s.Engine) - path := deliverToArchive(t, s) + _, path := deliverToArchive(t, s) require.FileExists( t, path+"-wal", "an open archive should have a -wal for the stop to remove", @@ -338,7 +351,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) { lc := startEngineViaHook(t, s.Engine) - deliverToArchive(t, s) + targetID, _ := deliverToArchive(t, s) release := make(chan struct{}) @@ -352,7 +365,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) { requireStopHookExpires(t, lc.hooks[0], "delivery engine") require.True( - t, s.Engine.ExportArchiveHandleOpen(s.WebhookID), + t, s.Engine.ExportArchiveHandleOpen(targetID), "a stop that timed out must not close archive writers", ) } diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 13d1625..7ebb016 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -5,11 +5,12 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "net/http/httptest" "os" - "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -20,10 +21,10 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/metrics" ) @@ -34,31 +35,18 @@ const testContentType = "application/json" func testWebhookDB(t *testing.T) *gorm.DB { t.Helper() - dbPath := filepath.Join( - t.TempDir(), "events-test.db", + // Opened by the manager the service uses, so these tests cannot + // pass against a schema, journal and locking settings or write + // guard production does not use. + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.DiscardHandler), ) - // Opened the way the service opens a per-webhook database, so - // these tests cannot pass against journal and locking settings - // production does not use. - sqlDB, err := database.OpenSQLite( - dbPath, database.SQLiteModeCreate, - ) + t.Cleanup(func() { _ = mgr.CloseAll() }) + + db, err := mgr.GetDB(uuid.New().String()) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Event{}, - &database.Delivery{}, - &database.DeliveryResult{}, - )) - return db } @@ -129,6 +117,18 @@ func seedDelivery( return d } +// assertStoredTime asserts that text, a timestamp as SQLite holds it, +// is in the form GORM stores created_at in: the layout of +// time.Time.String, with no " m=+…" monotonic clock reading after it. +func assertStoredTime(t *testing.T, text string) { + t.Helper() + + _, err := time.Parse( + "2006-01-02 15:04:05.999999999 -0700 MST", text, + ) + assert.NoError(t, err, "stored as %q", text) +} + // httpDeliveryFixture holds the task and delivery needed // for HTTP delivery tests. type httpDeliveryFixture struct { @@ -348,23 +348,15 @@ func TestDeliverDatabase_ImmediateSuccess( db := testWebhookDB(t) - // The database target archives for real now, so the engine - // needs a webhook DB manager to locate the data directory. - e := delivery.NewTestEngineWithDB( - nil, - database.NewTestWebhookDBManager(t.TempDir()), - slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - &http.Client{Timeout: 5 * time.Second}, - 1, - ) + // The database target archives for real, so the engine needs + // the target in the main database and a data directory. + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") event := seedEvent(t, db, `{"db":"target"}`) - d := seedDatabaseTargetDelivery(t, db, event, "") + d := seedDatabaseTargetDelivery(t, db, event, tgt) - e.ExportDeliverDatabase(db, d) + env.eng.ExportDeliverDatabase(db, d) var updated database.Delivery @@ -1020,6 +1012,62 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) { ) } +// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine +// reads the state of an http or a slack target's circuit breaker, with +// what is left of its cooldown while it is open, and no cooldown while +// it is half-open, once it closes, or for a target with no breaker. +func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) { + t.Parallel() + + e := testEngine(t, 1) + + httpID := uuid.New().String() + slackID := uuid.New().String() + + state, cooldown := e.StateAndCooldown(httpID) + assert.Equal(t, delivery.CircuitClosed, state, "no breaker") + assert.Zero(t, cooldown, "no breaker") + + httpCB := delivery.NewTestCircuitBreaker(1, time.Hour) + e.ExportSetCircuitBreaker(httpID, httpCB) + + slackCB := delivery.NewTestCircuitBreaker(1, time.Hour) + e.ExportSetSlackCircuitBreaker(slackID, slackCB) + + httpCB.RecordFailure() + slackCB.RecordFailure() + + for _, id := range []string{httpID, slackID} { + state, cooldown := e.StateAndCooldown(id) + assert.Equal(t, delivery.CircuitOpen, state) + assert.Greater(t, cooldown, 59*time.Minute) + assert.LessOrEqual(t, cooldown, time.Hour) + } + + httpCB.RecordSuccess() + slackCB.RecordSuccess() + + for _, id := range []string{httpID, slackID} { + state, cooldown := e.StateAndCooldown(id) + assert.Equal(t, delivery.CircuitClosed, state, "closed") + assert.Zero(t, cooldown, "closed") + } + + // A breaker with no cooldown goes half-open on the first Allow + // after it trips, letting that one delivery through to test the + // target. + halfOpenID := uuid.New().String() + halfOpenCB := delivery.NewTestCircuitBreaker(1, 0) + e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB) + + halfOpenCB.RecordFailure() + require.True(t, halfOpenCB.Allow()) + + state, cooldown = e.StateAndCooldown(halfOpenID) + assert.Equal(t, delivery.CircuitHalfOpen, state) + assert.Zero(t, cooldown, "half-open") +} + func TestParseHTTPConfig_Valid(t *testing.T) { t.Parallel() @@ -1061,6 +1109,21 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) { ) } +func TestParseHTTPConfig_Undecodable(t *testing.T) { + t.Parallel() + + e := testEngine(t, 1) + + _, err := e.ExportParseHTTPConfig( + `{"url":"https://example.com/hook","timeout":"soon"}`, + ) + + assert.Error(t, err, + "config that does not decode should return error, "+ + "even when the part that did names a URL", + ) +} + func TestScheduleRetry_SendsToRetryChannel( t *testing.T, ) { @@ -1244,11 +1307,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { testContentType, receivedHeaders.Get("Content-Type"), ) +} - assert.Equal(t, - "webhooker/1.0", - receivedHeaders.Get("User-Agent"), +// A response that ends before the length it announced is an error, not +// a short body. +func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "100") + _, _ = w.Write([]byte("cut short")) + }, + ), ) + defer ts.Close() + + e := testEngine(t, 1) + + _, body, _, err := e.ExportDoHTTPRequest( + context.TODO(), + &delivery.HTTPTargetConfig{URL: ts.URL}, + &database.Event{}, + ) + + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Empty(t, body) } // The event's stored inbound headers carry the same Content-Type the @@ -1317,6 +1402,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) { ContentType: tc.event, }, cfg, + "webhooker/dev", ) assert.Equal(t, @@ -1326,6 +1412,34 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) { } } +// Stored inbound headers that do not decode forward nothing, not the +// part of them that happened to decode. +func TestApplyRequestHeaders_UndecodableInboundForwardsNothing( + t *testing.T, +) { + t.Parallel() + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + "https://target.example.com/hook", + http.NoBody, + ) + require.NoError(t, err) + + names := delivery.ExportApplyRequestHeaders( + req, + &database.Event{ + Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`, + }, + &delivery.HTTPTargetConfig{}, + "webhooker/dev", + ) + + assert.Empty(t, names) + assert.Empty(t, req.Header.Get("X-Custom")) +} + func TestProcessDelivery_RoutesToCorrectHandler( t *testing.T, ) { @@ -1333,32 +1447,27 @@ func TestProcessDelivery_RoutesToCorrectHandler( db := testWebhookDB(t) - // The database target archives for real now, so the engine - // needs a webhook DB manager to locate the data directory. - e := delivery.NewTestEngineWithDB( - nil, - database.NewTestWebhookDBManager(t.TempDir()), - slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - &http.Client{Timeout: 5 * time.Second}, - 1, - ) + // The database target archives for real, so the engine needs + // the target in the main database and a data directory. + env := setupArchiveTest(t) + archive := env.seedDatabaseTarget(t, "") tests := []struct { name string targetType database.TargetType + targetID string wantStatus database.DeliveryStatus }{ { "database target", database.TargetTypeDatabase, + archive.ID, database.DeliveryStatusDelivered, }, { "log target", database.TargetTypeLog, + uuid.New().String(), database.DeliveryStatusDelivered, }, } @@ -1368,7 +1477,7 @@ func TestProcessDelivery_RoutesToCorrectHandler( t.Parallel() runRoutingSubtest( - t, db, e, tt.targetType, + t, db, env.eng, tt.targetType, tt.targetID, tt.wantStatus, ) }) @@ -1380,6 +1489,7 @@ func runRoutingSubtest( db *gorm.DB, e *delivery.Engine, targetType database.TargetType, + targetID string, wantStatus database.DeliveryStatus, ) { t.Helper() @@ -1387,8 +1497,7 @@ func runRoutingSubtest( event := seedEvent(t, db, `{"routing":"test"}`) dlv := seedDelivery( - t, db, event.ID, - uuid.New().String(), + t, db, event.ID, targetID, database.DeliveryStatusPending, ) @@ -1875,6 +1984,10 @@ func assertLogLineComplete( "log line must contain the full request headers", ) + assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery), + "log line must contain the query string", + ) + assert.Contains(t, out, event.EntrypointID, "log line must contain the entrypoint id", ) @@ -1897,6 +2010,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) { event := seedEvent( t, db, `{"log-body-marker":"abc123"}`, ) + event.RawQuery = eventQuery dlv := seedDelivery( t, db, event.ID, uuid.New().String(), diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 9dd2531..8c9aeb4 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -9,6 +9,7 @@ import ( "net/url" "time" + "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" @@ -40,11 +41,6 @@ const ( ExportPendingSweepMinAge = pendingSweepMinAge ) -// ExportIsBlockedIP exposes isBlockedIP for testing. -func ExportIsBlockedIP(ip net.IP) bool { - return isBlockedIP(ip) -} - // NewTestGuard builds an SSRF Guard from an explicit egress // allowlist, without going through config. Passing no prefixes // yields the default guard, which blocks every private/reserved @@ -70,6 +66,11 @@ func ExportBlockedNetworks() []*net.IPNet { return blockedNetworks } +// ExportBlockedPublicNetworks exposes blockedPublicNetworks. +func ExportBlockedPublicNetworks() []*net.IPNet { + return blockedPublicNetworks +} + // ExportIsForwardableHeader exposes isForwardableHeader. func ExportIsForwardableHeader(name string) bool { return isForwardableHeader(name) @@ -82,8 +83,9 @@ func ExportApplyRequestHeaders( req *http.Request, event *database.Event, cfg *HTTPTargetConfig, + userAgent string, ) []string { - return applyRequestHeaders(req, event, cfg) + return applyRequestHeaders(req, event, cfg, userAgent) } // ExportTruncate exposes truncate for testing. @@ -150,6 +152,16 @@ func (e *Engine) ExportDeliverSlack( ) } +// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no +// target type, so no metric moves. +func (e *Engine) ExportUpdateDeliveryStatus( + webhookDB *gorm.DB, + d *database.Delivery, + status database.DeliveryStatus, +) error { + return e.updateDeliveryStatus(webhookDB, d, "", status) +} + // ExportProcessNewTask exposes processNewTask. func (e *Engine) ExportProcessNewTask( ctx context.Context, task *Task, @@ -200,6 +212,14 @@ func (e *Engine) ExportSetCircuitBreaker( e.httpTarget.circuitBreakers.Store(targetID, cb) } +// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the +// slack target. +func (e *Engine) ExportSetSlackCircuitBreaker( + targetID string, cb *CircuitBreaker, +) { + e.slackTarget.circuitBreakers.Store(targetID, cb) +} + // ExportParseHTTPConfig exposes parseHTTPConfig. func (e *Engine) ExportParseHTTPConfig( configJSON string, @@ -389,7 +409,7 @@ func NewTestEngine( deliveryCh: make(chan Task, deliveryChannelSize), retryCh: make(chan Task, retryChannelSize), workers: workers, - mtr: metrics.Default(), + mtr: metrics.New(prometheus.NewRegistry()), } e.initTargets(client) @@ -404,7 +424,7 @@ func NewTestEngineSmallRetry( e := &Engine{ log: log, retryCh: make(chan Task, 1), - mtr: metrics.Default(), + mtr: metrics.New(prometheus.NewRegistry()), } e.initTargets(nil) @@ -427,7 +447,7 @@ func NewTestEngineWithDB( deliveryCh: make(chan Task, deliveryChannelSize), retryCh: make(chan Task, retryChannelSize), workers: workers, - mtr: metrics.Default(), + mtr: metrics.New(prometheus.NewRegistry()), } e.initTargets(client) @@ -435,8 +455,7 @@ func NewTestEngineWithDB( } // ExportSetMetrics substitutes the engine's metric set, so a test can -// assert on collectors registered on a private registry instead of -// the process-wide ones every other test is also moving. +// assert on collectors registered on a registry it holds. func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { e.mtr = mtr } @@ -463,7 +482,7 @@ func NewTestCircuitBreaker( type ExportArchivedEvent = archivedEvent // ExportArchiveWriter wraps an archiveWriter so black-box tests -// can exercise the per-webhook archive file mechanics. +// can exercise the archive file mechanics. type ExportArchiveWriter struct { w *archiveWriter } @@ -482,23 +501,38 @@ func NewExportArchiveWriter( return &ExportArchiveWriter{w: w} } -// Write archives a row through the writer. +// Write archives a row through the writer, into the file named +// without a period. func (e *ExportArchiveWriter) Write( row ExportArchivedEvent, expiry time.Duration, ) error { - return e.w.write(row, expiry) + return e.w.write(row, expiry, "") +} + +// WritePeriod archives a row through the writer, into the file for +// period. +func (e *ExportArchiveWriter) WritePeriod( + row ExportArchivedEvent, expiry time.Duration, period string, +) error { + return e.w.write(row, expiry, period) } // Open opens the archive file, pruning when expiry is positive. func (e *ExportArchiveWriter) Open(expiry time.Duration) error { - return e.w.open(expiry) + return e.w.open(e.w.path, expiry) } // Reopen closes and reopens the archive file. func (e *ExportArchiveWriter) Reopen( expiry time.Duration, ) error { - return e.w.reopen(expiry) + return e.w.reopen(e.w.path, expiry) +} + +// SetNow replaces the clock the writer measures its reopen +// debounce on. +func (e *ExportArchiveWriter) SetNow(now func() time.Time) { + e.w.now = now } // Reopens reports how many times the file has been opened. @@ -522,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string { func (e *ExportArchiveWriter) OpenExisting( expiry time.Duration, ) error { - return e.w.openMode(archiveModeExisting, expiry) + return e.w.openMode(e.w.path, archiveModeExisting, expiry) } // SweepExpired runs an idle sweep of the archive. @@ -538,6 +572,12 @@ func (e *ExportArchiveWriter) Evict() { e.w.evict() } +// Rename gives the archive file a new name in the same directory, +// as a rename of the webhook or target does. +func (e *ExportArchiveWriter) Rename(name string) error { + return e.w.rename(name) +} + // HandleOpen reports whether the writer currently holds an open // archive handle. func (e *ExportArchiveWriter) HandleOpen() bool { @@ -557,16 +597,16 @@ func (e *ExportArchiveWriter) Same( } // ExportArchiveWriterFor returns the archive writer the registry -// currently caches for a webhook, or nil when none is cached. It -// never creates one, so a test can hold a reference to the very -// writer an eviction is about to detach. +// currently caches for a database target, or nil when none is +// cached. It never creates one, so a test can hold a reference to +// the very writer an eviction is about to detach. func (e *Engine) ExportArchiveWriterFor( - webhookID string, + targetID string, ) *ExportArchiveWriter { e.dbTarget.mu.Lock() defer e.dbTarget.mu.Unlock() - w, ok := e.dbTarget.writers[webhookID] + w, ok := e.dbTarget.writers[targetID] if !ok { return nil } @@ -575,26 +615,26 @@ func (e *Engine) ExportArchiveWriterFor( } // ExportHasArchiveWriter reports whether the database target -// currently caches an archive writer for a webhook. +// type currently caches an archive writer for a target. func (e *Engine) ExportHasArchiveWriter( - webhookID string, + targetID string, ) bool { e.dbTarget.mu.Lock() defer e.dbTarget.mu.Unlock() - _, ok := e.dbTarget.writers[webhookID] + _, ok := e.dbTarget.writers[targetID] return ok } // ExportArchiveHandleOpen reports whether the cached archive -// writer for a webhook holds an open database handle. It +// writer for a target holds an open database handle. It // returns false when no writer is cached. func (e *Engine) ExportArchiveHandleOpen( - webhookID string, + targetID string, ) bool { e.dbTarget.mu.Lock() - w, ok := e.dbTarget.writers[webhookID] + w, ok := e.dbTarget.writers[targetID] e.dbTarget.mu.Unlock() if !ok { @@ -608,12 +648,12 @@ func (e *Engine) ExportArchiveHandleOpen( } // ExportEnsureArchiveWriter creates (if needed) and returns the -// archive file path of the cached writer for a webhook, so a +// archive file path of the cached writer for a target, so a // test can prime the registry the way a delivery would. func (e *Engine) ExportEnsureArchiveWriter( - webhookID string, + targetID string, ) (string, error) { - w, err := e.dbTarget.writerFor(webhookID) + w, err := e.dbTarget.writerFor(targetID) if err != nil { return "", err } @@ -621,14 +661,14 @@ func (e *Engine) ExportEnsureArchiveWriter( return w.path, nil } -// ExportSweepWriterFor takes a webhook's registry writer exactly +// ExportSweepWriterFor takes a target's registry writer exactly // as the idle sweep does, reporting whether the sweep had to // create the entry. It lets a test drive the registry through the // sweep's own entry point instead of choreographing goroutines. func (e *Engine) ExportSweepWriterFor( - webhookID string, + targetID string, ) (*ExportArchiveWriter, bool, error) { - w, created, err := e.dbTarget.sweepWriterFor(webhookID) + w, created, err := e.dbTarget.sweepWriterFor(targetID) if err != nil { return nil, false, err } @@ -639,9 +679,9 @@ func (e *Engine) ExportSweepWriterFor( // ExportReleaseSweepWriter releases a sweep-created registry entry // exactly as a finished sweep does. func (e *Engine) ExportReleaseSweepWriter( - webhookID string, w *ExportArchiveWriter, + targetID string, w *ExportArchiveWriter, ) { - e.dbTarget.releaseSweepWriter(webhookID, w.w) + e.dbTarget.releaseSweepWriter(targetID, w.w) } // NewTestArchiveSweeper builds an ArchiveSweeper backed by the diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index 08a0f69..059aa5a 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -48,8 +48,9 @@ func fSweepSetup( // // Every caller drives the dispatch paths synchronously and has already // waited for them to return, so anything they queued is in the channel -// by now. The short grace covers nothing but scheduler jitter, and is -// kept small because one of these tests runs the drain forty times. +// by now, and nothing is waited for. A timer here would race the queued +// tasks: on a busy host it can be due by the time select looks, and +// select picks at random among the cases that are ready. func fDrain(e *delivery.Engine) []delivery.Task { var out []delivery.Task @@ -59,7 +60,7 @@ func fDrain(e *delivery.Engine) []delivery.Task { out = append(out, task) case task := <-e.ExportRetryCh(): out = append(out, task) - case <-time.After(25 * time.Millisecond): + default: return out } } @@ -461,7 +462,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) { // TestRedispatchStampsTheRow pins the cadence control: a stranded // delivery that has just been handed out is not selected again by the -// next tick a minute later. +// next tick a minute later. The stamp is stored in the same form as +// created_at. func TestRedispatchStampsTheRow(t *testing.T) { t.Parallel() @@ -492,4 +494,13 @@ func TestRedispatchStampsTheRow(t *testing.T) { "a re-dispatched delivery must be stamped so the "+ "next tick does not select it again", ) + + var createdAt, updatedAt string + + require.NoError(t, s.WebhookDB.Raw( + `SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + FROM deliveries WHERE id = ?`, d.ID, + ).Row().Scan(&createdAt, &updatedAt)) + assertStoredTime(t, createdAt) + assertStoredTime(t, updatedAt) } diff --git a/internal/delivery/metrics_test.go b/internal/delivery/metrics_test.go index c9d95a2..e7dccba 100644 --- a/internal/delivery/metrics_test.go +++ b/internal/delivery/metrics_test.go @@ -35,9 +35,8 @@ const ( ) // mIsolate gives the setup's engine a metric set registered on a -// private registry. The process-wide collectors are moved by every -// other delivery test running in parallel, so exact assertions are -// only possible against a registry this test owns. +// registry this test holds, so its exact assertions can gather from +// it. func mIsolate( t *testing.T, s iSetup, ) *prometheus.Registry { diff --git a/internal/delivery/queue_depth_gormlog_test.go b/internal/delivery/queue_depth_gormlog_test.go index 8c89734..51ccd75 100644 --- a/internal/delivery/queue_depth_gormlog_test.go +++ b/internal/delivery/queue_depth_gormlog_test.go @@ -5,7 +5,6 @@ import ( "context" "log/slog" "net/http" - "path/filepath" "strings" "sync" "testing" @@ -14,11 +13,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/gormlog" ) // qdAggregateMarker identifies the queue-depth aggregate in the @@ -49,27 +46,13 @@ func (q *qdSyncBuf) String() string { // qdMainDB opens a main database whose GORM logger is the service's // adapter, writing through log. -func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB { +func qdMainDB(t *testing.T, log *slog.Logger) *database.Database { t.Helper() - sqlDB, err := database.OpenSQLite( - filepath.Join(t.TempDir(), "main-gormlog.db"), - database.SQLiteModeCreate, - ) + db, err := database.Open(t.TempDir(), log) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, - &gorm.Config{Logger: gormlog.New(log)}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Webhook{}, - &database.Target{}, - )) + t.Cleanup(func() { _ = db.Close() }) return db } @@ -106,18 +89,18 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) { )) mainDB := qdMainDB(t, log) - dbMgr := database.NewTestWebhookDBManagerWithLogger( - t.TempDir(), log, + dbMgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), log, ) webhookID := uuid.New().String() webhookDB := iSeedWebhookDB(t, dbMgr, webhookID) - iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog") + iCreateWebhook(t, mainDB.DB(), webhookID, "queue-depth-gormlog") targetID := uuid.New().String() - iCreateTarget(t, mainDB, targetID, webhookID, + iCreateTarget(t, mainDB.DB(), targetID, webhookID, "queue-depth-gormlog-target", database.TargetTypeHTTP, iHTTPConfig("https://example.com/hook"), 3, ) @@ -136,7 +119,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) { ) engine := delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), + mainDB, dbMgr, log, &http.Client{Timeout: 5 * time.Second}, diff --git a/internal/delivery/recovery_durability_test.go b/internal/delivery/recovery_durability_test.go index a139eeb..2dc23c8 100644 --- a/internal/delivery/recovery_durability_test.go +++ b/internal/delivery/recovery_durability_test.go @@ -376,3 +376,97 @@ func TestFailedResultWriteLeavesDeliveryRecoverable( database.DeliveryStatusPending, ) } + +// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same +// rule for a target with retries: whatever the receiver answered, the +// delivery stays pending and no retry is scheduled. The circuit breaker +// still learns the answer, because it describes the target's health, +// not the database's. +func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable( + t *testing.T, +) { + t.Parallel() + + // The "send succeeded" case starts with the breaker tripped open, + // so the delivery goes out as its probe and only a recorded + // success closes it again. + tests := []struct { + name string + answer int + tripped bool + wantBreaker delivery.CircuitState + }{ + {"send succeeded", http.StatusOK, true, delivery.CircuitClosed}, + {"send failed", http.StatusBadGateway, false, delivery.CircuitOpen}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := newISetup(t) + targetID := uuid.New().String() + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.answer) + }, + )) + defer ts.Close() + + event := iSeedEvent( + t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`, + ) + + d := iSeedDelivery( + t, s.WebhookDB, event.ID, targetID, + database.DeliveryStatusPending, + ) + + require.NoError( + t, + s.WebhookDB.Exec("drop table delivery_results").Error, + ) + + // A single failure opens this breaker, and with no + // cooldown an open breaker lets the next delivery + // through as a probe. + cb := delivery.NewTestCircuitBreaker(1, 0) + if tc.tripped { + cb.RecordFailure() + } + + s.Engine.ExportSetCircuitBreaker(targetID, cb) + + full := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "unwritable", + Type: database.TargetTypeHTTP, + Config: iHTTPConfig(ts.URL), + MaxRetries: 3, + }, + } + full.ID = d.ID + + sched := &recordingScheduler{} + + s.Engine.ExportDeliverHTTPWithScheduler( + context.Background(), s.WebhookDB, full, + &delivery.Task{ + DeliveryID: d.ID, + TargetID: targetID, + AttemptNum: 1, + }, + sched, + ) + + iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending) + assert.Empty(t, sched.delays, "no retry may be scheduled") + assert.Equal(t, tc.wantBreaker, cb.State()) + }) + } +} diff --git a/internal/delivery/redirect_test.go b/internal/delivery/redirect_test.go index e5d5347..507327f 100644 --- a/internal/delivery/redirect_test.go +++ b/internal/delivery/redirect_test.go @@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) { "Content-Type": testContentType, }, }, + "webhooker/dev", ) assert.Equal(t, diff --git a/internal/delivery/ssrf.go b/internal/delivery/ssrf.go index 0fa73b3..b2c68a0 100644 --- a/internal/delivery/ssrf.go +++ b/internal/delivery/ssrf.go @@ -25,36 +25,64 @@ var ( errNoIPs = errors.New( "hostname resolved to no IP addresses", ) - errBlockedIP = errors.New( - "blocked private, reserved or cloud metadata address", + // ErrBlockedPrivateOrReservedIP reports an address in the + // default blocklist's private and reserved ranges, + // blockedNetworks. + ErrBlockedPrivateOrReservedIP = errors.New( + "blocked private or reserved address", + ) + // errBlockedPublicMetadata reports a public address on the + // default blocklist, one in blockedPublicNetworks. + errBlockedPublicMetadata = errors.New( + "blocked cloud metadata address", ) errBlockedMetadata = errors.New( - "blocked link-local or cloud instance metadata " + - "address: ALLOWED_EGRESS_CIDRS cannot open it", + "blocked link-local, cloud instance metadata or " + + "unspecified address: ALLOWED_EGRESS_CIDRS cannot open it", ) errInvalidScheme = errors.New( "only http and https are allowed", ) ) -// blockedNetworks is the default blocklist: the private and -// reserved IP ranges, plus the public cloud metadata addresses, -// that are blocked to prevent SSRF attacks. An operator can -// permit specific blocks out of this set with -// ALLOWED_EGRESS_CIDRS; see Guard. +// blockedNetworks and blockedPublicNetworks together are the +// default blocklist: the private and reserved IP ranges, plus +// the public cloud metadata addresses, that are blocked to +// prevent SSRF attacks. An operator can permit specific blocks +// out of this set with ALLOWED_EGRESS_CIDRS; see Guard. +// +// blockedNetworks holds the private and reserved IP ranges. // //nolint:gochecknoglobals // package-level network list is appropriate here var blockedNetworks []*net.IPNet -// alwaysBlockedNetworks are the ranges no configuration can -// open: the link-local blocks and the cloud instance metadata -// endpoints that live outside them. Reaching one is credential -// or user-data theft rather than delivery to an internal -// service, so a supplied CIDR that covers such an address still -// leaves it blocked. +// blockedPublicNetworks holds the default blocklist's public +// addresses, kept apart from blockedNetworks so that they are +// refused as cloud metadata addresses, never as private or +// reserved ones. // -// Inclusion criterion — an address belongs here only if BOTH -// hold, and every entry below satisfies both: +// A public address belongs on the default blocklist only if it +// hands credentials, user data or bootstrap material to whatever +// can reach it, without the caller presenting anything; it goes +// in this list. A provider's other public addresses are not +// refused, since reaching them can be legitimate and no list of +// them could be complete. +// +//nolint:gochecknoglobals // package-level network list is appropriate here +var blockedPublicNetworks []*net.IPNet + +// alwaysBlockedNetworks are the ranges no configuration can +// open, so a supplied CIDR that covers one still leaves it +// blocked. An entry is here for one of two reasons: it is a +// metadata endpoint (the link-local blocks and the cloud +// instance metadata endpoints that live outside them), or it is +// an unspecified address. Reaching a metadata endpoint is +// credential or user-data theft rather than delivery to an +// internal service. +// +// Inclusion criterion for metadata endpoints — one belongs here +// only if BOTH hold, and every metadata entry below satisfies +// both: // // 1. It is a fixed address assigned by the provider, or a // range reserved by IANA — never one the operator chose. @@ -65,8 +93,8 @@ var blockedNetworks []*net.IPNet // not cheaply rotated. // // Both halves are load-bearing, so use them to refuse a -// candidate and say why. An endpoint disclosing only the -// operator's own inventory (instance id, region, disks, NICs) +// metadata candidate and say why. An endpoint disclosing only +// the operator's own inventory (instance id, region, disks, NICs) // fails (2): letting a delivery target reach the operator's own // infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to // provide. But (2) is not "IAM credentials only" either — @@ -81,12 +109,21 @@ var blockedNetworks []*net.IPNet // when it clears both halves. Nothing in this list can be // reopened, so putting a public address here leaves the operator // no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS -// exists to remove. Default-block it in blockedNetworks instead, -// which an allowlist can override. +// exists to remove. Default-block it in blockedPublicNetworks +// instead, which an allowlist can override. // // This is a criterion, not an enumeration of every metadata // address in existence. // +// The unspecified addresses 0.0.0.0 and :: are here for a +// separate reason: they disclose nothing, but no host can have +// either, and on Linux a connection to one reaches this host's +// own loopback. Listing them means an allowlist reaches loopback +// only through an entry that covers a loopback address +// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that +// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing +// else lives at either address, so refusing them costs nothing. +// // Every entry is either already in blockedNetworks — this list is // what makes it unconditional — or an alternate encoding of // 169.254.169.254 that Contains does not match against @@ -106,23 +143,49 @@ var alwaysBlockedNetworks []*net.IPNet //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup func init() { blockedNetworks = mustParseCIDRs([]string{ + // IPv4 loopback. "127.0.0.0/8", + // RFC 1918 private network. "10.0.0.0/8", + // RFC 1918 private network. "172.16.0.0/12", + // RFC 1918 private network. "192.168.0.0/16", + // IPv4 link-local. "169.254.0.0/16", + // "This network", holding the IPv4 unspecified address 0.0.0.0. "0.0.0.0/8", + // Carrier-grade NAT shared address space. "100.64.0.0/10", + // IETF protocol assignments. "192.0.0.0/24", + // IPv4 documentation (TEST-NET-1). "192.0.2.0/24", + // Benchmarking. "198.18.0.0/15", + // IPv4 documentation (TEST-NET-2). "198.51.100.0/24", + // IPv4 documentation (TEST-NET-3). "203.0.113.0/24", + // IPv4 multicast. "224.0.0.0/4", + // Reserved, including the broadcast address. "240.0.0.0/4", + // IPv6 loopback. "::1/128", + // IPv6 unspecified address. + "::/128", + // IPv6 unique local addresses. "fc00::/7", + // IPv6 link-local. "fe80::/10", + // IPv6 multicast. + "ff00::/8", + // IPv6 documentation. + "2001:db8::/32", + }) + + blockedPublicNetworks = mustParseCIDRs([]string{ // Azure WireServer, a public address that serves VM credentials. "168.63.129.16/32", }) @@ -179,6 +242,14 @@ func init() { // allowlist from opening it. "192.0.0.192/32", + // The unspecified addresses, each of which reaches this + // host's loopback on Linux. + // + // IPv4 unspecified address, inside the blocked 0.0.0.0/8. + "0.0.0.0/32", + // IPv6 unspecified address. + "::/128", + // 169.254.169.254 as an IPv4-compatible IPv6 address. "::a9fe:a9fe/128", // 169.254.169.254 behind the NAT64 well-known prefix. @@ -218,13 +289,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool { return false } -// isBlockedIP checks whether an IP address falls within -// the default blocklist, before any operator allowlist is -// considered. -func isBlockedIP(ip net.IP) bool { - return matchesAny(blockedNetworks, ip) -} - // Guard makes every SSRF decision in the process. // // It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and @@ -322,10 +386,12 @@ func (g *Guard) allows(ip net.IP) bool { // The order is the policy: // // 1. alwaysBlockedNetworks is refused before the allowlist is -// consulted, so no configured CIDR reaches link-local or a -// cloud metadata endpoint at a non-public address. +// consulted, so no configured CIDR reaches link-local, a +// cloud metadata endpoint at a non-public address, or an +// unspecified address. // 2. The allowlist is consulted next, so a listed private -// network becomes reachable. +// network, or a listed public address on the default +// blocklist, becomes reachable. // 3. Everything else keeps the default blocklist's answer. func (g *Guard) checkIP(ip net.IP) error { if matchesAny(alwaysBlockedNetworks, ip) { @@ -338,9 +404,15 @@ func (g *Guard) checkIP(ip net.IP) error { return nil } - if isBlockedIP(ip) { + if matchesAny(blockedNetworks, ip) { return fmt.Errorf( - "target IP %s: %w", ip, errBlockedIP, + "target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP, + ) + } + + if matchesAny(blockedPublicNetworks, ip) { + return fmt.Errorf( + "target IP %s: %w", ip, errBlockedPublicMetadata, ) } diff --git a/internal/delivery/ssrf_allowlist_test.go b/internal/delivery/ssrf_allowlist_test.go index 314865c..1a200b7 100644 --- a/internal/delivery/ssrf_allowlist_test.go +++ b/internal/delivery/ssrf_allowlist_test.go @@ -23,6 +23,10 @@ const ( metadataIP = "169.254.169.254" metadataURL = "http://" + metadataIP + "/latest/meta-data/" + // linkLocalIPv4 is the IPv4 link-local block, which holds + // metadataIP. + linkLocalIPv4 = "169.254.0.0/16" + // loopbackHookURL is a target on this host: blocked by // default, reachable only once an operator allowlists // loopback. @@ -164,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) { // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // case: cloud instance metadata endpoints are credential theft -// rather than delivery to an internal service, so no allowlist -// reaches one. Every guard below names a CIDR that covers its -// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and -// CGNAT blocks an operator would really list — and the address -// must stay refused anyway, on both the validation and the -// delivery path. +// rather than delivery to an internal service, and the +// unspecified addresses 0.0.0.0 and :: reach this host's loopback +// on Linux, so no allowlist reaches any of them. Every guard +// below names a CIDR that covers its target — including +// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an +// operator would really list — and the address must stay +// refused anyway, on both the validation and the delivery path. func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { t.Parallel() @@ -215,15 +220,17 @@ type metadataAlwaysRefusedCase struct { } // metadataAlwaysRefusedCases enumerates every unconditionally -// blocked address together with an allowlist entry that would -// otherwise reach it. Split by family of address only to stay -// under the function-length limit. +// blocked address (link-local, the cloud metadata endpoints and +// the unspecified addresses) together with an allowlist entry +// that would otherwise reach it. Split by family of address only +// to stay under the function-length limit. func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { cases := linkLocalRefusedCases() cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...) + cases = append(cases, encodedMetadataRefusedCases()...) - return append(cases, encodedMetadataRefusedCases()...) + return append(cases, unspecifiedRefusedCases()...) } // linkLocalRefusedCases covers the link-local blocks, including @@ -237,7 +244,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase { }, { name: "whole link-local block", - allow: "169.254.0.0/16", + allow: linkLocalIPv4, target: metadataURL, }, { @@ -363,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase { } } +// unspecifiedRefusedCases covers the unspecified addresses, each +// of which reaches this host's loopback on Linux. +func unspecifiedRefusedCases() []metadataAlwaysRefusedCase { + return []metadataAlwaysRefusedCase{ + { + name: "IPv4 unspecified address under 0.0.0.0/0", + allow: allowAllIPv4, + target: "http://0.0.0.0:8080/hook", + }, + { + name: "IPv6 unspecified address under ::/0", + allow: allowAllIPv6, + target: "http://[::]:8080/hook", + }, + } +} + // TestGuardAllowlist_PublicUnaffected asserts the allowlist does // not narrow anything: public addresses were reachable before it // existed and stay reachable, whether or not a list is set. @@ -412,6 +436,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) { "WireServer must be refused by the default blocklist, "+ "which an allowlist can override", ) + require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP, + "WireServer is public, not private or reserved", + ) assertDialRefused(t, defaultGuard, target) @@ -496,7 +523,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) { want := []string{ // IPv4 link-local: the 169.254.169.254 metadata // service on AWS, Azure and others. - "169.254.0.0/16", + linkLocalIPv4, // IPv6 link-local. "fe80::/10", // AWS IPv6 IMDS, inside the ULA space an operator may @@ -517,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) { // Oracle Cloud Classic metadata, inside the blocked // 192.0.0.0/24. "192.0.0.192/32", + // The IPv4 and IPv6 unspecified addresses, each of + // which reaches this host's loopback on Linux. + "0.0.0.0/32", + "::/128", // 169.254.169.254 as an IPv4-compatible IPv6 address. "::a9fe:a9fe/128", // 169.254.169.254 behind the NAT64 well-known prefix. @@ -526,6 +557,94 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) { assert.Equal(t, want, got) } +// TestDefaultBlocklist_PinnedSet pins each list of the default +// blocklist on its own, the private and reserved ranges in +// blockedNetworks and the public addresses in +// blockedPublicNetworks, so moving an entry from one list to the +// other fails it. For the first address of each entry it then +// checks that the default guard refuses it, and that listing the +// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional +// set holds that address. +func TestDefaultBlocklist_PinnedSet(t *testing.T) { + t.Parallel() + + // public marks an entry of blockedPublicNetworks; every other + // entry belongs in blockedNetworks. + tests := []struct { + cidr string + public bool + reopenable bool + }{ + {cidr: "127.0.0.0/8", reopenable: true}, + {cidr: "10.0.0.0/8", reopenable: true}, + {cidr: "172.16.0.0/12", reopenable: true}, + {cidr: "192.168.0.0/16", reopenable: true}, + {cidr: linkLocalIPv4, reopenable: false}, + // Its first address, 0.0.0.0, is in the unconditional set. + {cidr: "0.0.0.0/8", reopenable: false}, + {cidr: "100.64.0.0/10", reopenable: true}, + {cidr: "192.0.0.0/24", reopenable: true}, + {cidr: "192.0.2.0/24", reopenable: true}, + {cidr: "198.18.0.0/15", reopenable: true}, + {cidr: "198.51.100.0/24", reopenable: true}, + {cidr: "203.0.113.0/24", reopenable: true}, + {cidr: "224.0.0.0/4", reopenable: true}, + {cidr: "240.0.0.0/4", reopenable: true}, + {cidr: "::1/128", reopenable: true}, + {cidr: "::/128", reopenable: false}, + {cidr: "fc00::/7", reopenable: true}, + {cidr: "fe80::/10", reopenable: false}, + {cidr: "ff00::/8", reopenable: true}, + {cidr: "2001:db8::/32", reopenable: true}, + {cidr: "168.63.129.16/32", public: true, reopenable: true}, + } + + wantPrivate := make([]string, 0, len(tests)) + wantPublic := make([]string, 0, len(tests)) + + for _, tt := range tests { + if tt.public { + wantPublic = append(wantPublic, tt.cidr) + } else { + wantPrivate = append(wantPrivate, tt.cidr) + } + } + + gotPrivate := make([]string, 0, len(tests)) + for _, n := range delivery.ExportBlockedNetworks() { + gotPrivate = append(gotPrivate, n.String()) + } + + gotPublic := make([]string, 0, len(tests)) + for _, n := range delivery.ExportBlockedPublicNetworks() { + gotPublic = append(gotPublic, n.String()) + } + + assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks") + assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks") + + for _, tt := range tests { + t.Run(tt.cidr, func(t *testing.T) { + t.Parallel() + + prefix := netip.MustParsePrefix(tt.cidr) + ip := net.IP(prefix.Addr().AsSlice()) + + require.Error(t, + delivery.NewTestGuard().ExportCheckIP(ip), + "the default guard must refuse %s", ip, + ) + + err := delivery.NewTestGuard(prefix).ExportCheckIP(ip) + if tt.reopenable { + assert.NoError(t, err, "listing %s must open it", tt.cidr) + } else { + assert.Error(t, err, "listing %s must not open it", tt.cidr) + } + }) + } +} + // requireLoopback fails the test unless rawURL's host is a // loopback address, so the allowlist test cannot silently stop // exercising a blocked range. diff --git a/internal/delivery/ssrf_test.go b/internal/delivery/ssrf_test.go index 14454e9..f2816bd 100644 --- a/internal/delivery/ssrf_test.go +++ b/internal/delivery/ssrf_test.go @@ -10,7 +10,7 @@ import ( "sneak.berlin/go/webhooker/internal/delivery" ) -func TestIsBlockedIP_PrivateRanges(t *testing.T) { +func TestGuardCheckIP_PrivateRanges(t *testing.T) { t.Parallel() tests := []struct { @@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) { "failed to parse IP %s", tt.ip, ) + refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil + assert.Equal(t, tt.blocked, - delivery.ExportIsBlockedIP(ip), - "isBlockedIP(%s) = %v, want %v", + refused, + "default guard refuses %s = %v, want %v", tt.ip, - delivery.ExportIsBlockedIP(ip), + refused, tt.blocked, ) }) @@ -99,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) { } } +// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation +// covers the unspecified addresses and the IPv6 multicast and +// documentation ranges: with no allowlist set, each is refused +// both when a target is created and when a delivery dials it. +func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation( + t *testing.T, +) { + t.Parallel() + + guard := delivery.NewTestGuard() + + targets := []string{ + // The unspecified addresses. On Linux a connection to + // either reaches this host's loopback. + "http://0.0.0.0:8080/hook", + "http://[::]:8080/hook", + // IPv6 multicast, all nodes. + "http://[ff02::1]/hook", + // IPv6 documentation. + "http://[2001:db8::1]/hook", + } + + for _, target := range targets { + t.Run(target, func(t *testing.T) { + t.Parallel() + + require.Error(t, + guard.ValidateTargetURL(context.Background(), target), + "%s must be refused at target creation", target, + ) + + assertDialRefused(t, guard, target) + }) + } +} + func TestValidateTargetURL_Allowed(t *testing.T) { t.Parallel() diff --git a/internal/delivery/target.go b/internal/delivery/target.go index 17c3bf5..5ccb7d4 100644 --- a/internal/delivery/target.go +++ b/internal/delivery/target.go @@ -105,6 +105,7 @@ func (e *Engine) initTargets(client *http.Client) { dbT := &databaseTarget{eng: e} e.httpTarget = httpT + e.slackTarget = slackT e.dbTarget = dbT e.targets = map[database.TargetType]Target{ diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index 42da682..3e76a48 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -39,8 +39,13 @@ type TargetConfigForm struct { // Timeout is the HTTP target's per-request timeout in seconds, // empty when unset. Timeout string + // ForwardQuery is the HTTP target's setting that passes each + // event's query string on to it. + ForwardQuery bool // Expiry is the database (archive) target's row expiry. Expiry string + // Rotation is the database (archive) target's rotation. + Rotation string } // NewTargetConfigForm parses a target's stored configuration into @@ -62,9 +67,10 @@ func NewTargetConfigForm( } return TargetConfigForm{ - URL: cfg.URL, - Headers: FormatTargetHeaders(cfg.Headers), - Timeout: FormatTargetTimeout(cfg.Timeout), + URL: cfg.URL, + Headers: FormatTargetHeaders(cfg.Headers), + Timeout: FormatTargetTimeout(cfg.Timeout), + ForwardQuery: cfg.ForwardQuery, }, nil case database.TargetTypeSlack: cfg, err := parseSlackConfig(t.Config) @@ -85,11 +91,13 @@ func NewTargetConfigForm( } } -// databaseConfigForm parses an archive target's optional expiry. -// An absent or empty configuration is the keep-forever default and -// yields an empty field, so re-saving the form unchanged stores the -// same empty configuration it started with. An expiry that is set -// but not a valid duration is an error, not a blank field. +// databaseConfigForm parses an archive target's optional expiry and +// rotation. An absent, empty or never expiry yields an empty expiry, +// on which the edit form starts at never; saving it unchanged stores +// never, which means the same as an empty expiry. An absent rotation +// is empty too, and the form starts at none. An expiry that is set +// but not a valid duration, or a rotation that is not one of the +// four, is an error, not a blank field. func databaseConfigForm( configJSON string, ) (TargetConfigForm, error) { @@ -106,8 +114,15 @@ func databaseConfigForm( ) } + err = ValidateArchiveRotation(cfg.Rotation) + if err != nil { + return TargetConfigForm{}, err + } + + form := TargetConfigForm{Rotation: cfg.Rotation} + if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { - return TargetConfigForm{}, nil + return form, nil } err = ValidateArchiveExpiry(cfg.Expiry) @@ -115,5 +130,7 @@ func databaseConfigForm( return TargetConfigForm{}, err } - return TargetConfigForm{Expiry: cfg.Expiry}, nil + form.Expiry = cfg.Expiry + + return form, nil } diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index fc09a40..2f02285 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -1,9 +1,9 @@ package delivery import ( - "encoding/json" "fmt" "strconv" + "time" "sneak.berlin/go/webhooker/internal/database" ) @@ -97,7 +97,7 @@ func targetConfigFields( ) []ConfigField { switch t.Type { case database.TargetTypeSlack: - return slackConfigFields(t.Config) + return slackConfigFields(t) case database.TargetTypeHTTP: return httpConfigFields(t) case database.TargetTypeDatabase: @@ -119,10 +119,11 @@ func unavailableConfigFields() []ConfigField { }} } -// slackConfigFields describes a Slack target. Only the masked -// webhook URL is shown; the full URL is the credential. -func slackConfigFields(configJSON string) []ConfigField { - cfg, err := parseSlackConfig(configJSON) +// slackConfigFields describes a Slack target: its masked +// webhook URL and its retry count. Only the masked URL is +// shown; the full URL is the credential. +func slackConfigFields(t *database.Target) []ConfigField { + cfg, err := parseSlackConfig(t.Config) if err != nil { return unavailableConfigFields() } @@ -130,7 +131,7 @@ func slackConfigFields(configJSON string) []ConfigField { return []ConfigField{{ Label: "Webhook URL", Value: cfg.MaskedWebhookURL(), - }} + }, maxRetriesField(t)} } // httpConfigFields describes an HTTP target: its destination @@ -170,63 +171,97 @@ func httpConfigFields(t *database.Target) []ConfigField { }) } - return append(fields, retryFields(t)...) -} - -// retryFields describes a target's retry settings, which live -// on the target row rather than in its configuration blob. -func retryFields(t *database.Target) []ConfigField { - retries := strconv.Itoa(t.MaxRetries) - if t.MaxRetries == 0 { - retries += " (fire-and-forget)" - } - - fields := []ConfigField{{ - Label: "Max Retries", - Value: retries, - }} - - if t.MaxQueueSize > 0 { + if cfg.ForwardQuery { fields = append(fields, ConfigField{ - Label: "Max Queue Size", - Value: strconv.Itoa(t.MaxQueueSize), + Label: "Query string", + Value: "passed on to this target", }) } + fields = append(fields, maxRetriesField(t)) + return fields } -// databaseConfigFields describes an archive target. Its -// configuration is optional, and an absent or empty expiry -// means the archive is kept forever. An expiry that is set -// but not a valid duration is reported as unavailable rather -// than echoed back. +// maxRetriesField describes a target's retry count, which lives +// on the target row rather than in its configuration blob. A +// stored 0 makes a single attempt, so it is shown as 1. +func maxRetriesField(t *database.Target) ConfigField { + attempts := strconv.Itoa(t.MaxRetries) + if t.MaxRetries == 0 { + attempts = "1 (fire-and-forget: no retries, no circuit breaker)" + } + + return ConfigField{ + Label: "Delivery attempts", + Value: attempts, + } +} + +// databaseConfigFields describes an archive target by its +// expiry in plain units, such as "30 days", or "never" when +// the archive is kept forever, and by its rotation. An expiry +// that is set but not a valid duration, or a rotation that is +// not one of the four, is reported as unavailable rather than +// echoed back. func databaseConfigFields(configJSON string) []ConfigField { - expiry := archiveExpiryNever + expiry, err := parseArchiveExpiry(configJSON) + if err != nil { + return unavailableConfigFields() + } - if configJSON != "" { - var cfg databaseTargetConfig + rotation, err := parseArchiveRotation(configJSON) + if err != nil { + return unavailableConfigFields() + } - err := json.Unmarshal([]byte(configJSON), &cfg) - if err != nil { - return unavailableConfigFields() - } - - if cfg.Expiry != "" { - if ValidateArchiveExpiry(cfg.Expiry) != nil { - return unavailableConfigFields() - } - - expiry = cfg.Expiry - } + value := archiveExpiryNever + if expiry > 0 { + value = plainDuration(expiry) } return []ConfigField{{ - Label: "Archive Expiry", - Value: expiry, + Label: "Archive expiry", + Value: value, + }, { + Label: "Archive rotation", + Value: rotation, }} } +// plainDuration writes a positive duration as a count of the +// largest whole unit it divides into: "30 days", "12 hours", +// "1 minute". A duration with a fraction of a second is +// written as Go writes it. +func plainDuration(d time.Duration) string { + const day = 24 * time.Hour + + units := []struct { + size time.Duration + name string + }{ + {day, "day"}, + {time.Hour, "hour"}, + {time.Minute, "minute"}, + {time.Second, "second"}, + } + + for _, unit := range units { + if d%unit.size != 0 { + continue + } + + count := int64(d / unit.size) + if count == 1 { + return "1 " + unit.name + } + + return fmt.Sprintf("%d %ss", count, unit.name) + } + + return d.String() +} + // MaskedWebhookURL returns the Slack webhook URL reduced to // its scheme and host, with the path, query and any userinfo // elided. The path segments are the credential, so none of diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 41211fa..85ffde0 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -32,6 +32,7 @@ const ( viewMaskedOrigin = viewExampleOrigin + "/..." viewUnavailable = "(unavailable)" viewExpiryNever = "never" + viewMaxRetries = "Delivery attempts" ) func TestMaskedWebhookURL(t *testing.T) { @@ -157,9 +158,7 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) { t, slackTargetName+" (deleted)", view.DisplayName(), ) assert.Equal( - t, - map[string]string{"Webhook URL": slackMaskedURL}, - fieldMap(view.Config), + t, viewFor(t, slackTarget()).Config, view.Config, ) } @@ -189,7 +188,30 @@ func TestNewTargetViews_Slack(t *testing.T) { assert.Equal( t, - map[string]string{"Webhook URL": slackMaskedURL}, + map[string]string{ + "Webhook URL": slackMaskedURL, + viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", + }, + fieldMap(view.Config), + ) +} + +// TestNewTargetViews_SlackRetries proves a Slack target shows +// its retry count the same way an HTTP target does. +func TestNewTargetViews_SlackRetries(t *testing.T) { + t.Parallel() + + target := slackTarget() + target.MaxRetries = 2 + + view := viewFor(t, target) + + assert.Equal( + t, + map[string]string{ + "Webhook URL": slackMaskedURL, + viewMaxRetries: "2", + }, fieldMap(view.Config), ) } @@ -201,9 +223,9 @@ func TestNewTargetViews_HTTP(t *testing.T) { Type: database.TargetTypeHTTP, Config: `{"url":"` + viewExampleHook + `",` + `"timeout":30,` + - `"headers":{"Authorization":"Bearer sekrit"}}`, - MaxRetries: 5, - MaxQueueSize: 100, + `"headers":{"Authorization":"Bearer sekrit"},` + + `"forwardQuery":true}`, + MaxRetries: 5, }) fields := fieldMap(view.Config) @@ -214,8 +236,8 @@ func TestNewTargetViews_HTTP(t *testing.T) { "Destination URL": viewMaskedOrigin, "Timeout": "30s", "Headers": "1 configured", - "Max Retries": "5", - "Max Queue Size": "100", + "Query string": "passed on to this target", + viewMaxRetries: "5", }, fields, ) @@ -238,7 +260,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) { t, map[string]string{ "Destination URL": viewMaskedOrigin, - "Max Retries": "0 (fire-and-forget)", + viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", }, fieldMap(view.Config), ) @@ -281,14 +303,20 @@ func TestNewTargetViews_Database(t *testing.T) { }{ "empty config": {config: "", want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever}, - "explicit": { - config: `{"expiry":"720h"}`, - want: "720h", - }, "never literal": { config: `{"expiry":"` + viewExpiryNever + `"}`, want: viewExpiryNever, }, + "1h": {config: `{"expiry":"1h"}`, want: "1 hour"}, + "12h": {config: `{"expiry":"12h"}`, want: "12 hours"}, + "24h": {config: `{"expiry":"24h"}`, want: "1 day"}, + "720h": {config: `{"expiry":"720h"}`, want: "30 days"}, + "2160h": {config: `{"expiry":"2160h"}`, want: "90 days"}, + "8760h": {config: `{"expiry":"8760h"}`, want: "365 days"}, + "36h": {config: `{"expiry":"36h"}`, want: "36 hours"}, + "1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"}, + "45s": {config: `{"expiry":"45s"}`, want: "45 seconds"}, + "1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"}, } for name, tc := range tests { @@ -302,13 +330,49 @@ func TestNewTargetViews_Database(t *testing.T) { assert.Equal( t, - map[string]string{"Archive Expiry": tc.want}, + map[string]string{ + "Archive expiry": tc.want, + "Archive rotation": rotationNone, + }, fieldMap(view.Config), ) }) } } +// TestNewTargetViews_DatabaseRotation proves the target list shows a +// database target's rotation, none when it has none stored. +func TestNewTargetViews_DatabaseRotation(t *testing.T) { + t.Parallel() + + // Each stored config, and the rotation the list shows for it. + tests := map[string]string{ + "": rotationNone, + `{"rotation":""}`: rotationNone, + } + + for _, rotation := range []string{ + rotationNone, rotationMonthly, rotationDaily, rotationHourly, + } { + tests[`{"rotation":"`+rotation+`"}`] = rotation + } + + for config, want := range tests { + t.Run(config, func(t *testing.T) { + t.Parallel() + + view := viewFor(t, database.Target{ + Type: database.TargetTypeDatabase, + Config: config, + }) + + assert.Equal( + t, want, fieldMap(view.Config)["Archive rotation"], + ) + }) + } +} + func TestNewTargetViews_Log(t *testing.T) { t.Parallel() @@ -356,6 +420,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) { Type: database.TargetTypeDatabase, Config: `{"expiry":"a fortnight"}`, }, + "invalid archive rotation": { + Type: database.TargetTypeDatabase, + Config: weeklyConfig, + }, } for name, target := range tests { diff --git a/internal/delivery/target_database.go b/internal/delivery/target_database.go index 7173b71..aead474 100644 --- a/internal/delivery/target_database.go +++ b/internal/delivery/target_database.go @@ -3,7 +3,7 @@ package delivery import ( "context" "fmt" - "path/filepath" + "strings" "sync" "time" @@ -11,22 +11,76 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// databaseTarget is a no-retry target that archives the -// full inbound event into a per-webhook archive SQLite file, -// separate from the per-webhook event database. The event is -// already persisted in the per-webhook event DB by the time -// delivery runs; the database target additionally writes a -// durable long-term copy into archive-{webhookID}.db and then -// records a single attempt whose outcome reflects whether the -// archive write succeeded. See archiveWriter for the -// close/reopen, auto-recreate, and expiry semantics. +// archiveNameMaxLen is how many characters of a webhook or target +// name an archive file name keeps. +const archiveNameMaxLen = 40 + +// databaseTarget is a no-retry target that archives the full +// inbound event into the target's own archive SQLite file, separate +// from the per-webhook event database. The event is already +// persisted in the per-webhook event DB by the time delivery runs; +// the database target additionally writes a durable long-term copy +// into the file ArchiveFileName names, with a period added when the +// target rotates (see archivePeriodPath), and then records a single +// attempt whose outcome reflects whether the archive write +// succeeded. See archiveWriter for the close/reopen, auto-recreate, +// and expiry semantics. type databaseTarget struct { eng *Engine + // writers holds one archive writer per database target, keyed + // by target ID. mu sync.Mutex writers map[string]*archiveWriter } +// ArchiveFileName returns the file name of a database target's +// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both +// names passed through archiveNamePart. The target ID keeps the +// name unique when two targets' names come out the same. +func ArchiveFileName(webhookName, targetName, targetID string) string { + return "archive-" + archiveNamePart(webhookName) + "-" + + archiveNamePart(targetName) + "-" + targetID + ".db" +} + +// archiveNamePart makes a webhook or target name safe to put in a +// file name. It is lowercased; ASCII letters and digits are kept, +// every other run of characters becomes a single "-", and no "-" is +// left at either end. It is cut to archiveNameMaxLen characters, and +// a name with nothing left is "unnamed". +func archiveNamePart(name string) string { + var b strings.Builder + + dash := false + + for _, r := range strings.ToLower(name) { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') { + dash = b.Len() > 0 + + continue + } + + if dash { + b.WriteByte('-') + + dash = false + } + + b.WriteRune(r) + } + + part := b.String() + if len(part) > archiveNameMaxLen { + part = strings.TrimRight(part[:archiveNameMaxLen], "-") + } + + if part == "" { + return "unnamed" + } + + return part +} + // Deliver implements Target. It archives the event, then // records one successful attempt and marks the delivery // delivered. An archiving error fails the delivery: the @@ -92,9 +146,10 @@ func (t *databaseTarget) Deliver( ) } -// archive writes the full event as a row into the webhook's -// archive database, honouring the optional per-target expiry -// parsed from the target config JSON. +// archive writes the full event as a row into the target's +// archive database, honouring the optional per-target expiry and +// rotation parsed from the target config JSON. With rotation, the +// event goes to the file for the period of its receive time. func (t *databaseTarget) archive(d *database.Delivery) error { webhookID := d.Event.WebhookID if webhookID == "" { @@ -106,7 +161,20 @@ func (t *databaseTarget) archive(d *database.Delivery) error { return err } - w, err := t.writerFor(webhookID) + rotation, err := parseArchiveRotation(d.Target.Config) + if err != nil { + return err + } + + // An event whose stored row was gone before its delivery ran has + // no receive time (see Engine.hydrateEvent), and goes to the + // file for now. + receivedAt := d.Event.CreatedAt + if receivedAt.IsZero() { + receivedAt = time.Now() + } + + w, err := t.writerFor(d.TargetID) if err != nil { return err } @@ -116,38 +184,40 @@ func (t *databaseTarget) archive(d *database.Delivery) error { WebhookID: webhookID, EntrypointID: d.Event.EntrypointID, Method: d.Event.Method, + RawQuery: d.Event.RawQuery, Headers: d.Event.Headers, Body: d.Event.Body, ContentType: d.Event.ContentType, } - return w.write(row, expiry) + return w.write(row, expiry, archivePeriod(rotation, receivedAt)) } -// writerFor returns the archiveWriter for a webhook, creating -// and caching it on first use. Each webhook has one writer so -// its close/reopen debounce state is shared across concurrent -// deliveries. The archive file lives beside the per-webhook -// event database in the data directory. +// writerFor returns the archive writer for a database target, +// creating and caching it on first use. Each target has one writer +// so its close/reopen debounce state is shared across concurrent +// deliveries, and so a rename and the idle sweep take the same lock +// as its writes. func (t *databaseTarget) writerFor( - webhookID string, + targetID string, ) (*archiveWriter, error) { - path, err := t.archivePath(webhookID) - if err != nil { - return nil, err - } - t.mu.Lock() defer t.mu.Unlock() - if t.writers == nil { - t.writers = make(map[string]*archiveWriter) - } - - w, ok := t.writers[webhookID] + w, ok := t.writers[targetID] if !ok { - w = newArchiveWriter(path, t.eng.log) - t.writers[webhookID] = w + var err error + + w, err = t.newWriter(targetID) + if err != nil { + return nil, err + } + + if t.writers == nil { + t.writers = make(map[string]*archiveWriter) + } + + t.writers[targetID] = w } // A delivery claims the entry: even if the idle sweep created @@ -159,40 +229,39 @@ func (t *databaseTarget) writerFor( } // sweepWriterFor returns the archive writer the idle sweep should -// prune a webhook through, together with whether the sweep itself -// created the registry entry. +// prune a target's archive through, together with whether the sweep +// itself created the registry entry. // // The sweep must route its prune through the registered writer so // the writer's mutex orders it against concurrent writes, but it // must never leave a registry entry behind: a sweep that ran -// concurrently with the webhook's deletion would otherwise +// concurrently with the target's deletion would otherwise // re-create an entry that nothing will ever evict again, which is // exactly the leak eviction exists to prevent. An entry the sweep // creates is therefore marked sweep-owned and handed back to // releaseSweepWriter when the sweep is done. func (t *databaseTarget) sweepWriterFor( - webhookID string, + targetID string, ) (*archiveWriter, bool, error) { - path, err := t.archivePath(webhookID) + t.mu.Lock() + defer t.mu.Unlock() + + w, ok := t.writers[targetID] + if ok { + return w, false, nil + } + + w, err := t.newWriter(targetID) if err != nil { return nil, false, err } - t.mu.Lock() - defer t.mu.Unlock() - if t.writers == nil { t.writers = make(map[string]*archiveWriter) } - w, ok := t.writers[webhookID] - if ok { - return w, false, nil - } - - w = newArchiveWriter(path, t.eng.log) w.sweepOwned = true - t.writers[webhookID] = w + t.writers[targetID] = w return w, true, nil } @@ -209,57 +278,92 @@ func (t *databaseTarget) sweepWriterFor( // delivery that adopted the writer keeps a registered, evictable // one. func (t *databaseTarget) releaseSweepWriter( - webhookID string, w *archiveWriter, + targetID string, w *archiveWriter, ) { t.mu.Lock() defer t.mu.Unlock() - cur, ok := t.writers[webhookID] + cur, ok := t.writers[targetID] if !ok || cur != w || !cur.sweepOwned { return } - delete(t.writers, webhookID) + delete(t.writers, targetID) } -// archivePath returns the archive file path for a webhook: it -// lives beside the per-webhook event database in the data -// directory. It does not touch the filesystem. -func (t *databaseTarget) archivePath( - webhookID string, -) (string, error) { +// newWriter builds the writer for a database target's archive. Its +// path is the one ArchivePath gives for the webhook and the target as +// the main database names them now; from then on only rename changes +// the name the writer uses. It does not touch the archive files. +func (t *databaseTarget) newWriter( + targetID string, +) (*archiveWriter, error) { if t.eng.dbManager == nil { - return "", errArchiveNoDataDir + return nil, errArchiveNoDataDir } - dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID)) + var target database.Target - return filepath.Join( - dir, fmt.Sprintf("archive-%s.db", webhookID), - ), nil + err := t.eng.database.DB(). + Preload("Webhook"). + First(&target, "id = ?", targetID).Error + if err != nil { + return nil, fmt.Errorf( + "loading database target %s: %w", targetID, err, + ) + } + + w := newArchiveWriter( + ArchivePath(t.eng.dbManager, &target.Webhook, &target), + t.eng.log, + ) + w.webhookID = target.WebhookID + + return w, nil } -// evict drops a webhook's archive writer from the registry and -// closes its handle, so a deleted webhook does not leave a -// writer (and an open archive handle within its debounce -// window) alive for the process lifetime. +// rename moves every one of a database target's archive files to the +// name for webhookName and targetName. It goes through the target's +// writer, so the move holds the lock that writes and the idle sweep +// take, and later writes use the new name. +// +// The writer is created if there is none, and it stays cached. The +// handlers rename before they save the new name, so until the save +// the main database still has the old one; a delivery in that window +// must find this writer rather than build one from the old name. +func (t *databaseTarget) rename( + targetID, webhookName, targetName string, +) error { + w, err := t.writerFor(targetID) + if err != nil { + return err + } + + return w.rename(ArchiveFileName(webhookName, targetName, targetID)) +} + +// evict drops a database target's archive writer from the registry +// and closes its handle, so a deleted target does not leave a +// writer (and an open archive handle within its debounce window) +// alive for the process lifetime. // // The map entry is removed under the registry lock, which is // then released before the handle is closed under the writer's // own lock: that ordering keeps the registry available to other -// webhooks while an in-flight write on this one drains, and +// targets while an in-flight write on this one drains, and // closing under the writer's lock means eviction can never race // a write. // -// Eviction is idempotent and silent for a webhook with no -// writer, which is the common case: a webhook with no database -// target never creates one. It never deletes the archive file. -func (t *databaseTarget) evict(webhookID string) { +// Eviction is idempotent and silent for a target with no writer, +// which is the common case: only a database target that has +// received an event or been renamed has one. It never deletes the +// archive file. +func (t *databaseTarget) evict(targetID string) { t.mu.Lock() - w, ok := t.writers[webhookID] + w, ok := t.writers[targetID] if ok { - delete(t.writers, webhookID) + delete(t.writers, targetID) } t.mu.Unlock() @@ -272,13 +376,41 @@ func (t *databaseTarget) evict(webhookID string) { t.eng.log.Info( "evicted archive writer", - "webhook_id", webhookID, + "target_id", targetID, "path", w.path, ) } +// evictWebhook evicts, exactly as evict does, the writer of every +// database target of a webhook. +func (t *databaseTarget) evictWebhook(webhookID string) { + t.mu.Lock() + + var gone []*archiveWriter + + for targetID, w := range t.writers { + if w.webhookID == webhookID { + delete(t.writers, targetID) + + gone = append(gone, w) + } + } + + t.mu.Unlock() + + for _, w := range gone { + w.evict() + + t.eng.log.Info( + "evicted archive writer", + "webhook_id", webhookID, + "path", w.path, + ) + } +} + // evictAll evicts every cached archive writer, exactly as evict -// does for one webhook. The engine calls it at shutdown, once its +// does for one target. The engine calls it at shutdown, once its // workers have returned. Closing the last handle on an archive // moves the contents of its -wal into the .db and removes the // -wal, so a clean stop leaves each archive as a single file. @@ -295,38 +427,25 @@ func (t *databaseTarget) evictAll() { } } -// sweepWebhook prunes one webhook's archive of rows older than -// expiry, without requiring a write. It returns nil (nothing to -// do) when the archive file does not exist, so a sweep never -// creates an archive for a webhook that has a database target -// but has never received an event. +// sweepArchive prunes one database target's archive of rows older +// than expiry, without requiring a write. A missing archive file is +// left missing (see sweepExpired), so a sweep never creates an +// archive for a target that has never received an event. // // It also never leaves a registry entry behind: an entry it had // to create to reach the writer's mutex is released again once -// the prune is done, so a sweep racing a webhook deletion cannot +// the prune is done, so a sweep racing a target deletion cannot // resurrect the writer the eviction just dropped. -func (t *databaseTarget) sweepWebhook( - webhookID string, expiry time.Duration, +func (t *databaseTarget) sweepArchive( + targetID string, expiry time.Duration, ) error { - path, err := t.archivePath(webhookID) - if err != nil { - return err - } - - // Check before taking a writer at all: a webhook whose - // archive has never been created gets no writer, no handle, - // and no file. - if !fileExists(path) { - return nil - } - - w, created, err := t.sweepWriterFor(webhookID) + w, created, err := t.sweepWriterFor(targetID) if err != nil { return err } if created { - defer t.releaseSweepWriter(webhookID, w) + defer t.releaseSweepWriter(targetID, w) } return w.sweepExpired(expiry) diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index 0937854..519af99 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -4,14 +4,17 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "log/slog" "os" + "path/filepath" "sync" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -41,7 +44,7 @@ const ( var ( // errArchiveMissingWebhookID is returned when an event to - // archive has no webhook id to key its archive file on. + // archive has no webhook id to record in its archive row. errArchiveMissingWebhookID = errors.New( "cannot archive event without a webhook id", ) @@ -61,13 +64,19 @@ var ( ) // errArchiveWriterEvicted is returned when a writer that has - // been evicted (its webhook was deleted, or its last database - // target was removed) is used again. An evicted writer is no - // longer in the registry, so reopening its file would leak a - // handle nothing owns. + // been evicted (its target or its webhook was deleted) is used + // again. An evicted writer is no longer in the registry, so + // reopening its file would leak a handle nothing owns. errArchiveWriterEvicted = errors.New( "archive writer has been evicted", ) + + // ErrArchiveNameTaken is returned when an archive cannot be + // renamed because a file already has the new name. That file may + // be an archive with rows of its own, so it is never replaced. + ErrArchiveNameTaken = errors.New( + "a file already has the archive's new name", + ) ) // databaseTargetConfig is the optional per-target JSON config @@ -77,10 +86,14 @@ type databaseTargetConfig struct { // archived rows are pruned, or "never" (the default) to // keep them forever. Expiry string `json:"expiry"` + + // Rotation is none (the default), monthly, daily or hourly: see + // archivePeriod. + Rotation string `json:"rotation"` } // archivedEvent is one fully captured webhook event stored in a -// per-webhook archive database for long-term retention. It is a +// database target's archive for long-term retention. It is a // self-contained copy — independent of the per-webhook event // database, which may prune events under its own retention. type archivedEvent struct { @@ -89,6 +102,7 @@ type archivedEvent struct { WebhookID string EntrypointID string Method string + RawQuery string Headers string Body string ContentType string @@ -170,33 +184,50 @@ func ValidateArchiveExpiry(expiry string) error { return nil } -// archiveWriter owns one per-webhook archive SQLite file. It -// serialises writes, and after each write closes and reopens +// archiveWriter owns one database target's archive SQLite files. +// It serialises writes, and after each write closes and reopens // the file (debounced to at most once per debounce window) so // an operator can move the file away for offline archiving. The // next write recreates a moved or removed file, because the // file is opened create-if-missing and its schema is migrated // on every open. type archiveWriter struct { - mu sync.Mutex - path string + mu sync.Mutex + + // path is the target's archive file as ArchivePath names it. A + // target that rotates writes to the files archivePeriodPath names + // for path and a period instead. + path string + + // current is the file db is open on. + current string + log *slog.Logger debounce time.Duration db *gorm.DB lastReopen time.Time reopens int + // now is the clock the reopen debounce is measured on. It is + // time.Now outside tests. + now func() time.Time + // evicted marks a writer that has been removed from the - // per-webhook registry. Its handle is closed and it must - // never open the file again: nothing holds it any more, so a - // reopen would leak the handle for the process lifetime. + // registry. Its handle is closed and it must never open the + // file again: nothing holds it any more, so a reopen would + // leak the handle for the process lifetime. evicted bool + // webhookID is the webhook the archive's target belongs to, + // so deleting the webhook can find its writers. It is set + // when the writer is created and never changes. + webhookID string + // sweepOwned marks a registry entry that the idle sweep - // created because no writer was cached for the webhook. The + // created because no writer was cached for the target. The // sweep removes such an entry again when it is done, so a // sweep can never leave — or resurrect — a registry entry - // for a webhook that has been deleted. A delivery that adopts + // for a target that has been deleted. A delivery that adopts // the writer clears the flag, handing the entry to the // registry proper. // @@ -215,15 +246,18 @@ func newArchiveWriter( path: path, log: log, debounce: archiveReopenDebounce, + now: time.Now, } } -// write appends the event as a row, then applies the debounced -// close/reopen. It recreates the archive file if it was moved -// or removed since the last open. A positive expiry prunes rows -// older than it on each (re)open. +// write appends the event as a row to the archive file for period +// (see archivePeriodPath), then applies the debounced close/reopen. +// When period names a different file from the one open, the open one +// is closed first. It recreates the archive file if it was moved or +// removed since the last open. A positive expiry prunes rows older +// than it on each (re)open. func (w *archiveWriter) write( - row archivedEvent, expiry time.Duration, + row archivedEvent, expiry time.Duration, period string, ) error { w.mu.Lock() defer w.mu.Unlock() @@ -234,53 +268,59 @@ func (w *archiveWriter) write( ) } - if w.db == nil || !fileExists(w.path) { - err := w.reopen(expiry) + file := archivePeriodPath(w.path, period) + + if w.db == nil || w.current != file || !fileExists(file) { + err := w.reopen(file, expiry) if err != nil { return err } } - row.ArchivedAt = time.Now() + // GORM's clock, the one that stamps created_at in the other + // databases, so this is stored in the same form. A bare time.Now() + // carries a monotonic clock reading, which the driver would store + // as an " m=+…" suffix. + row.ArchivedAt = w.db.NowFunc() err := w.db.Create(&row).Error if err != nil { return fmt.Errorf( - "archiving event to %s: %w", w.path, err, + "archiving event to %s: %w", file, err, ) } - if time.Since(w.lastReopen) >= w.debounce { - return w.reopen(expiry) + if w.now().Sub(w.lastReopen) >= w.debounce { + return w.reopen(file, expiry) } return nil } -// open opens (creating if missing) the archive file, migrates +// open opens (creating if missing) an archive file, migrates // its schema, records the reopen time, and prunes expired rows // when expiry is positive. -func (w *archiveWriter) open(expiry time.Duration) error { - return w.openMode(archiveModeCreate, expiry) +func (w *archiveWriter) open(file string, expiry time.Duration) error { + return w.openMode(file, archiveModeCreate, expiry) } -// openMode opens the archive file with the given SQLite URI +// openMode opens an archive file with the given SQLite URI // mode, migrates its schema, records the reopen time, and // prunes expired rows when expiry is positive. The write path // passes archiveModeCreate so a missing file is recreated; the // idle sweep passes archiveModeExisting so a missing file is an // error rather than a newly conjured empty archive. func (w *archiveWriter) openMode( - mode string, expiry time.Duration, + file, mode string, expiry time.Duration, ) error { // Opened through database.OpenSQLite so an archive file carries // the same WAL journaling, busy timeout, immediate-transaction // locking, and pool bounds as every other database file. See // internal/database/sqlite_open.go. - sqlDB, err := database.OpenSQLite(w.path, mode) + sqlDB, err := database.OpenSQLite(file, mode) if err != nil { return fmt.Errorf( - "opening archive database %s: %w", w.path, err, + "opening archive database %s: %w", file, err, ) } @@ -296,21 +336,22 @@ func (w *archiveWriter) openMode( return fmt.Errorf( "connecting to archive database %s: %w", - w.path, err, + file, err, ) } - err = gdb.AutoMigrate(&archivedEvent{}) + err = migrations.Apply(gdb, migrations.Archive) if err != nil { _ = sqlDB.Close() return fmt.Errorf( - "migrating archive database %s: %w", w.path, err, + "migrating archive database %s: %w", file, err, ) } w.db = gdb - w.lastReopen = time.Now() + w.current = file + w.lastReopen = w.now() w.reopens++ if expiry > 0 { @@ -320,12 +361,12 @@ func (w *archiveWriter) openMode( return nil } -// reopen closes any open handle and opens the file afresh. The +// reopen closes any open handle and opens file afresh. The // fresh open recreates the file if it was moved away. -func (w *archiveWriter) reopen(expiry time.Duration) error { +func (w *archiveWriter) reopen(file string, expiry time.Duration) error { w.close() - return w.open(expiry) + return w.open(file, expiry) } // close closes the underlying handle, if any. @@ -342,22 +383,56 @@ func (w *archiveWriter) close() { w.db = nil } -// sweepExpired prunes an archive that may have gone idle, with -// no write to trigger the usual on-reopen prune. It takes the -// writer's own mutex for the whole operation, so a sweep is -// ordered against concurrent writes rather than reaching around -// them to the file. +// sweepExpired prunes the target's archive files, which may have +// gone idle, with no write to trigger the usual on-reopen prune. It +// lists the files under the writer's own mutex, then takes the mutex +// again for one file at a time, so a write waits for at most one +// file's prune, and each prune is ordered against concurrent writes +// rather than reaching around them to the file. // -// It never creates the archive file: a missing file is skipped, -// and the reopen uses archiveModeExisting so SQLite itself -// refuses to create one if the file disappears between the -// check and the open. +// It never creates an archive file: it prunes only the files +// archiveFiles lists, skips one that is gone by the time it is +// reached (moved away, or renamed since the listing), and opens each +// with archiveModeExisting so SQLite itself refuses to create one if +// the file disappears between the check and the open. A file named +// for a period that the prune leaves empty is deleted. // // The archive is left CLOSED afterwards. An idle archive holding // no handle is what keeps the operator's move-the-file-away // workflow working; the next write reopens (and recreates) the // file as it always has. func (w *archiveWriter) sweepExpired(expiry time.Duration) error { + w.mu.Lock() + files, err := archiveFiles(w.path) + w.mu.Unlock() + + if err != nil { + return err + } + + var errs []error + + for _, file := range files { + err = w.sweepFile(file, expiry) + if errors.Is(err, errArchiveWriterEvicted) { + return err + } + + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// sweepFile prunes one of the target's archive files for sweepExpired, +// holding w.mu while it does. It skips a file that is gone, and deletes +// the file, with its -wal and -shm, when it is named for a period and +// the prune leaves it empty. +func (w *archiveWriter) sweepFile( + file archiveFile, expiry time.Duration, +) error { w.mu.Lock() defer w.mu.Unlock() @@ -367,7 +442,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error { ) } - if !fileExists(w.path) { + if !fileExists(file.path) { return nil } @@ -375,21 +450,133 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error { // freshly opened file, matching the write path's semantics. w.close() - err := w.openMode(archiveModeExisting, expiry) + err := w.openMode(file.path, archiveModeExisting, expiry) if err != nil { return err } + if file.period == "" { + w.close() + + return nil + } + + var rows int64 + + err = w.db.Model(&archivedEvent{}).Count(&rows).Error + w.close() + if err != nil { + return fmt.Errorf( + "counting rows in archive %s: %w", file.path, err, + ) + } + + if rows > 0 { + return nil + } + + for _, suffix := range []string{"", "-wal", "-shm"} { + err = os.Remove(file.path + suffix) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("deleting empty archive file: %w", err) + } + } + + w.log.Info("deleted empty archive file", "path", file.path) + + return nil +} + +// rename gives every one of the target's archive files the new +// name, keeping the period in the name of each (see +// archivePeriodPath), and the writer uses the files under that name +// from now on. The handle is closed first, which folds the -wal into +// the .db; any -wal or -shm still beside a file (left by a crash) is +// moved with it, because SQLite finds them by name. A target with no +// files is not an error: the operator may have moved them away, and +// the next write creates its file under the new name. +// +// If a file already has one of the new names, nothing is moved and +// the error is ErrArchiveNameTaken. If one file fails to move, those +// already moved are moved back before the error is returned, so the +// archive is never split across two names. +func (w *archiveWriter) rename(name string) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.evicted { + return fmt.Errorf( + "%w: %s", errArchiveWriterEvicted, w.path, + ) + } + + path := filepath.Join(filepath.Dir(w.path), name) + if path == w.path { + return nil + } + + files, err := archiveFiles(w.path) + if err != nil { + return err + } + + // from[i] moves to to[i]. + var from, to []string + + for _, file := range files { + renamed := archivePeriodPath(path, file.period) + + for _, suffix := range []string{"", "-wal", "-shm"} { + from = append(from, file.path+suffix) + to = append(to, renamed+suffix) + } + } + + for _, taken := range to { + if fileExists(taken) { + return fmt.Errorf( + "%w: %s", ErrArchiveNameTaken, filepath.Base(taken), + ) + } + } + + w.close() + + for i := range from { + err = os.Rename(from[i], to[i]) + if err == nil || errors.Is(err, fs.ErrNotExist) { + continue + } + + for j := range i { + backErr := os.Rename(to[j], from[j]) + if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) { + w.log.Error( + "failed to move archive file back", + "from", to[j], + "to", from[j], + "error", backErr, + ) + } + } + + return fmt.Errorf( + "renaming archive %s to %s: %w", from[i], to[i], err, + ) + } + + w.path = path + return nil } // evict closes the writer's handle and marks it unusable. It is -// called when the writer leaves the registry, either because the -// webhook was deleted or because its last database target was -// removed. The archive FILE is deliberately left on disk: it is -// long-term storage an operator may still want. +// called when the writer leaves the registry, because its target +// or its webhook was deleted, or at shutdown. The archive FILE is +// deliberately left on disk: it is long-term storage an operator +// may still want. func (w *archiveWriter) evict() { w.mu.Lock() defer w.mu.Unlock() @@ -414,7 +601,7 @@ func (w *archiveWriter) prune(expiry time.Duration) { if res.Error != nil { w.log.Error( "failed to prune expired archive rows", - "path", w.path, + "path", w.current, "error", res.Error, ) @@ -424,12 +611,81 @@ func (w *archiveWriter) prune(expiry time.Duration) { if res.RowsAffected > 0 { w.log.Info( "pruned expired archive rows", - "path", w.path, + "path", w.current, "rows_deleted", res.RowsAffected, ) } } +// ArchiveFileInfo is what the metadata of a database target's archive +// files says about them. +type ArchiveFileInfo struct { + // Files counts the files. + Files int + + // Size is the bytes on disk of the files and their -wal together. + Size int64 + + // Written is when a file or a -wal was last modified, whichever is + // latest: a write lands in the -wal first. + Written time.Time +} + +// StatArchive reads the metadata of a database target's archive +// files, given the path ArchivePath gives it (see archiveFiles), and +// of their -wal, without opening them. With no files, which is so +// before the first write and after the operator moved them away, the +// error wraps fs.ErrNotExist. +func StatArchive(path string) (ArchiveFileInfo, error) { + files, err := archiveFiles(path) + if err != nil { + return ArchiveFileInfo{}, err + } + + var info ArchiveFileInfo + + for _, file := range files { + db, err := os.Stat(file.path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return ArchiveFileInfo{}, err + } + + info.Files++ + info.Size += db.Size() + + if db.ModTime().After(info.Written) { + info.Written = db.ModTime() + } + + wal, err := os.Stat(file.path + "-wal") + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return ArchiveFileInfo{}, err + } + + info.Size += wal.Size() + + if wal.ModTime().After(info.Written) { + info.Written = wal.ModTime() + } + } + + if info.Files == 0 { + return ArchiveFileInfo{}, fmt.Errorf( + "no archive file for %s: %w", path, fs.ErrNotExist, + ) + } + + return info, nil +} + // fileExists reports whether a path currently exists. func fileExists(path string) bool { _, err := os.Stat(path) diff --git a/internal/delivery/target_database_evict_test.go b/internal/delivery/target_database_evict_test.go index 095823d..5022ba4 100644 --- a/internal/delivery/target_database_evict_test.go +++ b/internal/delivery/target_database_evict_test.go @@ -17,85 +17,109 @@ import ( "sneak.berlin/go/webhooker/internal/delivery" ) -// evictTestEngine builds an engine backed by a temporary data -// directory and returns it along with that directory. -func evictTestEngine(t *testing.T) (*delivery.Engine, string) { +// deliverTo archives one event to a database target, leaving the +// target's writer cached with its handle open. +func deliverTo( + t *testing.T, env *archiveEnv, tgt *database.Target, +) { t.Helper() - dataDir := t.TempDir() - - eng := delivery.NewTestEngineWithDB( - nil, - database.NewTestWebhookDBManager(dataDir), - archiveTestLogger(), - &http.Client{Timeout: 5 * time.Second}, - 1, - ) - - return eng, dataDir -} - -// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting -// a webhook drops its archive writer from the registry and -// closes the open archive handle, rather than leaving both -// alive for the process lifetime. -func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { - t.Parallel() - - eng, dataDir := evictTestEngine(t) - webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, "") - eng.ExportDeliverDatabase(webhookDB, d) - - webhookID := event.WebhookID - - require.True( - t, eng.ExportHasArchiveWriter(webhookID), - "a delivery should have cached an archive writer", - ) - require.True( - t, eng.ExportArchiveHandleOpen(webhookID), - "the writer should hold an open handle after a write", + env.eng.ExportDeliverDatabase( + webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), ) +} - eng.EvictWebhook(webhookID) +// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting +// a webhook drops the archive writers of its database targets +// from the registry and closes their open handles, rather than +// leaving them alive for the process lifetime, and leaves another +// webhook's writer alone. +func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { + t.Parallel() - assert.False( - t, eng.ExportHasArchiveWriter(webhookID), - "eviction should remove the registry entry", - ) - assert.False( - t, eng.ExportArchiveHandleOpen(webhookID), - "eviction should close the archive handle", - ) + env := setupArchiveTest(t) + first := env.seedDatabaseTarget(t, "") + second := env.addDatabaseTarget(t, first.WebhookID, "") + other := env.seedDatabaseTarget(t, "") - archivePath := filepath.Join( - dataDir, fmt.Sprintf("archive-%s.db", webhookID), + for _, tgt := range []*database.Target{first, second, other} { + deliverTo(t, env, tgt) + + require.True( + t, env.eng.ExportArchiveHandleOpen(tgt.ID), + "the writer should hold an open handle after a write", + ) + } + + env.eng.EvictWebhook(first.WebhookID) + + for _, tgt := range []*database.Target{first, second} { + assert.False( + t, env.eng.ExportHasArchiveWriter(tgt.ID), + "eviction should remove the registry entry", + ) + assert.False( + t, env.eng.ExportArchiveHandleOpen(tgt.ID), + "eviction should close the archive handle", + ) + assert.FileExists( + t, env.archivePath(tgt), + "eviction must not delete the archive file", + ) + } + + assert.True( + t, env.eng.ExportArchiveHandleOpen(other.ID), + "another webhook's writer must be left alone", ) +} + +// TestEvictTarget_LeavesOtherTargets proves that evicting one +// database target leaves the writer of another target of the same +// webhook in place. +func TestEvictTarget_LeavesOtherTargets(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + doomed := env.seedDatabaseTarget(t, "") + kept := env.addDatabaseTarget(t, doomed.WebhookID, "") + + deliverTo(t, env, doomed) + deliverTo(t, env, kept) + + env.eng.EvictTarget(doomed.ID) + + assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID)) assert.FileExists( - t, archivePath, + t, env.archivePath(doomed), "eviction must not delete the archive file", ) + assert.True( + t, env.eng.ExportArchiveHandleOpen(kept.ID), + "the other target's writer must be left alone", + ) } // TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe -// for the common case of a webhook that never had a database -// target, and that repeating it does not panic. +// for the common case of a webhook or target that never had an +// archive writer, and that repeating it does not panic. func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) { t.Parallel() - eng, _ := evictTestEngine(t) + env := setupArchiveTest(t) assert.NotPanics(t, func() { - eng.EvictWebhook("no-such-webhook") - eng.EvictWebhook("no-such-webhook") + env.eng.EvictWebhook("no-such-webhook") + env.eng.EvictWebhook("no-such-webhook") + env.eng.EvictTarget("no-such-target") + env.eng.EvictTarget("no-such-target") }) assert.False( - t, eng.ExportHasArchiveWriter("no-such-webhook"), + t, env.eng.ExportHasArchiveWriter("no-such-target"), "eviction must not create a writer", ) } @@ -289,17 +313,14 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( ) { t.Parallel() - eng, _ := evictTestEngine(t) - - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"archived":true}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, "") + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") // Prime the registry so the test can hold the very writer the // eviction is about to detach. - eng.ExportDeliverDatabase(webhookDB, d) + deliverTo(t, env, tgt) - w := eng.ExportArchiveWriterFor(event.WebhookID) + w := env.eng.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, w) require.True(t, w.HandleOpen()) @@ -309,7 +330,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( // eviction has to contend for the writer's mutex. race.awaitFirstWrite() - eng.EvictWebhook(event.WebhookID) + env.eng.EvictWebhook(tgt.WebhookID) sawEvicted, otherErr := race.wait() @@ -324,41 +345,33 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( "been evicted", ) assert.False( - t, eng.ExportHasArchiveWriter(event.WebhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "the registry entry must stay gone", ) } // TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction -// does not break archiving for a webhook that is still alive: a +// does not break archiving for a target that is still alive: a // subsequent delivery gets a brand new writer from the registry. // It says nothing about the evicted writer itself — that is what // TestEvictedWriter_WriteDoesNotReopenFile covers. func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { t.Parallel() - eng, _ := evictTestEngine(t) + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"archived":true}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, "") + deliverTo(t, env, tgt) + require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID)) - eng.ExportDeliverDatabase(webhookDB, d) - require.True( - t, eng.ExportHasArchiveWriter(event.WebhookID), - ) + env.eng.EvictWebhook(tgt.WebhookID) - eng.EvictWebhook(event.WebhookID) - - // A fresh delivery for the same webhook gets a brand new + // A fresh delivery for the same target gets a brand new // writer from the registry, so archiving keeps working. - second := seedDatabaseTargetDelivery( - t, webhookDB, event, "", - ) - eng.ExportDeliverDatabase(webhookDB, second) + deliverTo(t, env, tgt) assert.True( - t, eng.ExportHasArchiveWriter(event.WebhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "a later delivery should recreate the writer", ) } @@ -370,19 +383,16 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { t.Parallel() - eng, _ := evictTestEngine(t) + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"archived":true}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, "") + deliverTo(t, env, tgt) - eng.ExportDeliverDatabase(webhookDB, d) - - w := eng.ExportArchiveWriterFor(event.WebhookID) + w := env.eng.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, w) require.True(t, w.HandleOpen()) - require.NoError(t, eng.ExportStop(context.Background())) + require.NoError(t, env.eng.ExportStop(context.Background())) err := w.Write(evictTestRow("ev-after-stop"), 0) @@ -395,7 +405,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { "a refused write must not reopen the archive", ) assert.False( - t, eng.ExportHasArchiveWriter(event.WebhookID), + t, env.eng.ExportHasArchiveWriter(tgt.ID), "the stop should empty the registry", ) diff --git a/internal/delivery/target_database_export.go b/internal/delivery/target_database_export.go new file mode 100644 index 0000000..450c60e --- /dev/null +++ b/internal/delivery/target_database_export.go @@ -0,0 +1,387 @@ +package delivery + +import ( + "compress/gzip" + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + "unicode/utf8" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/gormlog" +) + +// archiveTableQuery counts the archive's table: 0 when the archive +// writer has created the file but not yet the table in it. +const archiveTableQuery = "SELECT count(*) FROM sqlite_master " + + "WHERE type = 'table' AND name = 'archived_events'" + +// ArchivePath returns where a database target's archive file is: in +// the data directory, beside the webhook's event database, under the +// name ArchiveFileName gives it. +func ArchivePath( + dbMgr *database.WebhookDBManager, + webhook *database.Webhook, + target *database.Target, +) string { + return filepath.Join( + filepath.Dir(dbMgr.DBPath(webhook.ID)), + ArchiveFileName(webhook.Name, target.Name, target.ID), + ) +} + +// ArchiveExportFileName returns the name a database target's archive +// downloads under: +// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names +// made safe as in ArchiveFileName and the time in UTC. +func ArchiveExportFileName( + webhookName, targetName string, at time.Time, +) string { + return "archive-" + archiveNamePart(webhookName) + "-" + + archiveNamePart(targetName) + "-" + + at.UTC().Format("20060102T150405Z") + ".json.gz" +} + +// ArchiveExport is a database target's archive listed for download. It +// opens one of the target's files at a time, only when its rows are +// about to be written out, and closes it before it opens the next, so +// an export holds at most one file open however many the target has. +// +// Each file is read on its own connection inside one read-only +// transaction, so its rows are written out as the file stood when it +// was opened. Archives are in WAL mode, where a reader works from a +// snapshot and never blocks a writer: archive writes go on while a file +// is open, and the export does not see them. SQLite cannot checkpoint +// a -wal past an open snapshot, so the open file's -wal grows until the +// export has written that file out. +type ArchiveExport struct { + // periods are the periods of the target's files when the export + // was listed, "" for the file without one, in the order + // archiveFiles lists them. + periods []string + + // lock is held while currentPath is called and a file is opened, + // so that a rename, which holds it too, cannot move the file in + // between. + lock sync.Locker + + // currentPath returns the path ArchivePath gives the target under + // the names stored for it now, which a rename may have changed since + // the export was listed. + currentPath func() (string, error) + + log *slog.Logger +} + +// exportFile is one archive file opened for an export. +type exportFile struct { + db *sql.DB + tx *gorm.DB + + // period is the period in the file's name, "" for none. + period string + + // empty is true for a file without the archive's table yet. + empty bool +} + +// exportedName is how an export names its webhook and its target. +type exportedName struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// NewArchiveExport lists a database target's archive files for export, +// given the path ArchivePath gives it (see archiveFiles). It opens none +// of them. Its caller holds lock, which every rename of the target's +// files runs under, from reading the names path is made of until it +// returns, so the files it lists are the ones those names give. +// +// WriteGzipJSON, called without lock held, finds each file again by its +// period under the path currentPath gives, holding lock while it does +// and while it opens the file, so a rename during the export loses no +// file. A file that is gone by then, emptied by the sweep or moved +// away, is skipped. The export never creates a file: with no files, it +// has no rows. +func NewArchiveExport( + path string, + lock sync.Locker, + currentPath func() (string, error), + log *slog.Logger, +) (*ArchiveExport, error) { + files, err := archiveFiles(path) + if err != nil { + return nil, err + } + + x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log} + + for _, file := range files { + x.periods = append(x.periods, file.period) + } + + return x, nil +} + +// openExportFile opens one archive file for an export and takes its +// snapshot. The transaction lasts as long as ctx does. +func openExportFile( + ctx context.Context, file archiveFile, log *slog.Logger, +) (*exportFile, error) { + db, err := database.OpenSQLite(file.path, archiveModeExisting) + if err != nil { + return nil, fmt.Errorf("opening archive %s: %w", file.path, err) + } + + gdb, err := gorm.Open( + sqlite.Dialector{Conn: db}, &gorm.Config{ + // Never leave this at GORM's default. See + // internal/gormlog. + Logger: gormlog.New(log), + }, + ) + if err != nil { + _ = db.Close() + + return nil, fmt.Errorf("opening archive %s: %w", file.path, err) + } + + // ReadOnly makes the driver begin a deferred transaction in place + // of the BEGIN IMMEDIATE the connection string asks for, so the + // export never takes the archive's write lock. + tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true}) + if tx.Error != nil { + _ = db.Close() + + return nil, fmt.Errorf( + "reading archive %s: %w", file.path, tx.Error, + ) + } + + // The transaction's first read is what takes the snapshot. + var tables int + + err = tx.Raw(archiveTableQuery).Row().Scan(&tables) + if err != nil { + _ = tx.Rollback() + _ = db.Close() + + return nil, fmt.Errorf("reading archive %s: %w", file.path, err) + } + + return &exportFile{ + db: db, tx: tx, period: file.period, empty: tables == 0, + }, nil +} + +// WriteGzipJSON writes the export to w as one gzipped JSON object: +// webhook and target, each an id and a name; exported_at; and +// archived_events, one object per archived row, keyed by column name, +// the files in the order archiveFiles lists them. A row from a file +// named for a period has "period" beside its columns. A body that is +// not valid UTF-8 cannot be a JSON string, so it is written in base64, +// with "body_encoding": "base64" beside it. +// +// Each row is written out before the next is read, so neither the +// archive nor its JSON is ever held in memory whole, and each file is +// closed once its rows are written, before the next is opened. When it +// returns, no file is open. After an error the gzip stream is left +// unfinished, so what was written does not decompress as a whole file. +func (x *ArchiveExport) WriteGzipJSON( + ctx context.Context, + w io.Writer, + webhook *database.Webhook, + target *database.Target, + exportedAt time.Time, +) error { + head, err := json.Marshal(map[string]any{ + "webhook": exportedName{ID: webhook.ID, Name: webhook.Name}, + "target": exportedName{ID: target.ID, Name: target.Name}, + "exported_at": exportedAt.UTC(), + }) + if err != nil { + return fmt.Errorf("encoding archive export: %w", err) + } + + zw := gzip.NewWriter(w) + + err = x.writeJSON(ctx, zw, head) + if err != nil { + return fmt.Errorf("writing archive export: %w", err) + } + + return zw.Close() +} + +// openFile finds the target's archive file for period under the path +// currentPath gives now, and opens it for the export, holding x.lock +// for both. For a file that is gone, the error wraps fs.ErrNotExist. +func (x *ArchiveExport) openFile( + ctx context.Context, period string, +) (*exportFile, error) { + x.lock.Lock() + defer x.lock.Unlock() + + path, err := x.currentPath() + if err != nil { + return nil, fmt.Errorf("finding archive file: %w", err) + } + + file := archiveFile{path: archivePeriodPath(path, period), period: period} + + _, err = os.Stat(file.path) + if err != nil { + return nil, err + } + + return openExportFile(ctx, file, x.log) +} + +// close ends the file's transaction and closes its connection. +func (f *exportFile) close() error { + _ = f.tx.Rollback() + + return f.db.Close() +} + +// writeJSON writes head with archived_events added as its last key, +// the rows going into it one at a time. +func (x *ArchiveExport) writeJSON( + ctx context.Context, w io.Writer, head []byte, +) error { + // head goes out without its closing brace, so that + // archived_events can follow it. + _, err := w.Write(head[:len(head)-1]) + if err != nil { + return err + } + + _, err = io.WriteString(w, `,"archived_events":[`) + if err != nil { + return err + } + + err = x.writeRows(ctx, w) + if err != nil { + return err + } + + _, err = io.WriteString(w, "\n]}\n") + + return err +} + +// writeRows writes the archived rows of each file to w, one per line, +// separated by commas, opening each file in turn and closing it once +// its rows are written. +func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error { + sep := "\n" + + for _, period := range x.periods { + f, err := x.openFile(ctx, period) + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return err + } + + sep, err = f.writeRows(ctx, w, sep) + + err = errors.Join(err, f.close()) + if err != nil { + return err + } + } + + return nil +} + +// writeRows writes the file's archived rows to w, oldest first, the +// first after sep and each other after ",\n". It returns what goes +// before the next row: sep again when the file had no rows. +func (f *exportFile) writeRows( + ctx context.Context, w io.Writer, sep string, +) (string, error) { + if f.empty { + return sep, nil + } + + rows, err := f.tx.WithContext(ctx). + Model(&archivedEvent{}).Order("id").Rows() + if err != nil { + return "", err + } + + defer func() { _ = rows.Close() }() + + for ; rows.Next(); sep = ",\n" { + var ev archivedEvent + + err = f.tx.ScanRows(rows, &ev) + if err != nil { + return "", err + } + + _, err = io.WriteString(w, sep) + if err != nil { + return "", err + } + + err = writeRow(w, &ev, f.period) + if err != nil { + return "", err + } + } + + return sep, rows.Err() +} + +// writeRow writes an archived row to w as a JSON object keyed by +// column name, its body in base64 when it is not valid UTF-8, with +// the period of its file beside them unless that is "". +func writeRow(w io.Writer, ev *archivedEvent, period string) error { + row := map[string]any{ + "id": ev.ID, + "event_id": ev.EventID, + "webhook_id": ev.WebhookID, + "entrypoint_id": ev.EntrypointID, + "method": ev.Method, + "raw_query": ev.RawQuery, + "headers": ev.Headers, + "body": ev.Body, + "content_type": ev.ContentType, + "archived_at": ev.ArchivedAt.UTC(), + } + + if !utf8.ValidString(ev.Body) { + row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body)) + row["body_encoding"] = "base64" + } + + if period != "" { + row["period"] = period + } + + line, err := json.Marshal(row) + if err != nil { + return err + } + + _, err = w.Write(line) + + return err +} diff --git a/internal/delivery/target_database_export_test.go b/internal/delivery/target_database_export_test.go new file mode 100644 index 0000000..fc6dd61 --- /dev/null +++ b/internal/delivery/target_database_export_test.go @@ -0,0 +1,573 @@ +package delivery_test + +import ( + "bufio" + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// The webhook and the target the export tests' archives belong to. +const ( + exportWebhookID = "wh-export" + exportWebhookName = "Orders (EU)" + exportTargetID = "tgt-export" + exportTargetName = "Long-term archive" +) + +// binaryBody is a body that is not valid UTF-8. +const binaryBody = "\xff\xfe\x00\x01binary\x80" + +// writeExportTo writes export to w as the archive of the export tests' +// webhook and target, exported at 2026-10-02T12:03:04Z. +func writeExportTo( + t *testing.T, export *delivery.ArchiveExport, w io.Writer, +) error { + t.Helper() + + return export.WriteGzipJSON( + t.Context(), w, + &database.Webhook{ + BaseModel: database.BaseModel{ID: exportWebhookID}, + Name: exportWebhookName, + }, + &database.Target{ + BaseModel: database.BaseModel{ID: exportTargetID}, + Name: exportTargetName, + }, + time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC), + ) +} + +// listExport lists the archive at path for export, as the archive of a +// target whose names do not change. +func listExport(t *testing.T, path string) *delivery.ArchiveExport { + t.Helper() + + return newExport(t, path, &sync.Mutex{}, func() (string, error) { + return path, nil + }) +} + +// newExport lists the archive at path for export, to find each file +// again under the path currentPath gives, holding lock while it does. +// Nothing else takes lock while it lists, so it does not hold lock. +func newExport( + t *testing.T, + path string, + lock sync.Locker, + currentPath func() (string, error), +) *delivery.ArchiveExport { + t.Helper() + + export, err := delivery.NewArchiveExport( + path, lock, currentPath, archiveTestLogger(), + ) + require.NoError(t, err) + + return export +} + +// exportArchive runs a whole export of the archive at path and returns +// its JSON, decompressed and parsed. +func exportArchive(t *testing.T, path string) map[string]any { + t.Helper() + + return writeExport(t, listExport(t, path)) +} + +// writeExport writes an opened export and returns its JSON, +// decompressed and parsed. Reading to the end makes the gzip reader +// check that the stream was finished. +func writeExport( + t *testing.T, export *delivery.ArchiveExport, +) map[string]any { + t.Helper() + + var buf bytes.Buffer + + require.NoError(t, writeExportTo(t, export, &buf)) + + zr, err := gzip.NewReader(&buf) + require.NoError(t, err) + + raw, err := io.ReadAll(zr) + require.NoError(t, err) + + var got map[string]any + + require.NoError(t, json.Unmarshal(raw, &got)) + + return got +} + +// exportedEvents returns an export's archived_events. +func exportedEvents(t *testing.T, got map[string]any) []map[string]any { + t.Helper() + + list, ok := got["archived_events"].([]any) + require.True(t, ok, "archived_events must be an array: %v", got) + + events := make([]map[string]any, len(list)) + + for i, v := range list { + events[i], ok = v.(map[string]any) + require.True(t, ok, "an archived event must be an object: %v", v) + } + + return events +} + +// exportedEventIDs returns the event_id of each of an export's +// archived_events. +func exportedEventIDs(t *testing.T, got map[string]any) []string { + t.Helper() + + events := exportedEvents(t, got) + ids := make([]string, 0, len(events)) + + for _, ev := range events { + ids = append(ids, fmt.Sprint(ev["event_id"])) + } + + return ids +} + +// TestArchiveExport_MatchesStoredRows proves an export holds the +// webhook, the target, the time, and every column of every stored +// row: a body that is valid UTF-8 as a string, and one that is not in +// base64, marked as such. +func TestArchiveExport_MatchesStoredRows(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + bodies := []string{`{"order":1}`, "plain text", "", binaryBody} + + for i, body := range bodies { + require.NoError(t, w.Write(delivery.ExportArchivedEvent{ + EventID: fmt.Sprintf("ev-%d", i), + WebhookID: exportWebhookID, + EntrypointID: "ep-1", + Method: "POST", + RawQuery: eventQuery, + Headers: `{"X-Test":["yes"]}`, + Body: body, + ContentType: testContentType, + }, 0)) + } + + var stored []delivery.ExportArchivedEvent + + require.NoError(t, openArchiveDBForRead(t, path). + Order("id").Find(&stored).Error) + + got := exportArchive(t, path) + + assert.Equal(t, + map[string]any{"id": exportWebhookID, "name": exportWebhookName}, + got["webhook"], + ) + assert.Equal(t, + map[string]any{"id": exportTargetID, "name": exportTargetName}, + got["target"], + ) + assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"]) + + events := exportedEvents(t, got) + require.Len(t, events, len(bodies)) + + for i, row := range stored { + assertExportedRow(t, row, events[i]) + } +} + +// assertExportedRow checks that ev, from an export, holds every column +// of the stored row. +func assertExportedRow( + t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any, +) { + t.Helper() + + archivedAt, err := time.Parse( + time.RFC3339Nano, fmt.Sprint(ev["archived_at"]), + ) + require.NoError(t, err) + assert.True(t, archivedAt.Equal(row.ArchivedAt)) + + assert.EqualValues(t, row.ID, ev["id"]) + assert.Equal(t, row.EventID, ev["event_id"]) + assert.Equal(t, row.WebhookID, ev["webhook_id"]) + assert.Equal(t, row.EntrypointID, ev["entrypoint_id"]) + assert.Equal(t, row.Method, ev["method"]) + assert.Equal(t, row.RawQuery, ev["raw_query"]) + assert.Equal(t, row.Headers, ev["headers"]) + assert.Equal(t, row.ContentType, ev["content_type"]) + + if row.Body != binaryBody { + assert.Equal(t, row.Body, ev["body"]) + assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev) + + return + } + + body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"])) + require.NoError(t, err) + assert.Equal(t, binaryBody, string(body)) + assert.Equal(t, "base64", ev["body_encoding"]) + assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev) +} + +// TestArchiveExport_Empty proves an archive with nothing in it exports +// as an empty archived_events: no file, which the export must not +// create; a file the archive writer has not yet put its table in; and +// a table with no rows. +func TestArchiveExport_Empty(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + missing := filepath.Join(dir, "missing.db") + noTable := filepath.Join(dir, "no-table.db") + noRows := filepath.Join(dir, "no-rows.db") + + require.NoError(t, os.WriteFile(noTable, nil, 0o600)) + require.NoError(t, + delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0). + Open(0), + ) + + for _, path := range []string{missing, noTable, noRows} { + assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path) + } + + for _, suffix := range archiveFileSuffixes() { + assert.NoFileExists(t, missing+suffix) + } +} + +// unlockHook is a sync.Locker that runs fn each time it is unlocked. An +// export unlocks its lock right after it opens a file. +type unlockHook struct { + sync.Mutex + + fn func() +} + +func (u *unlockHook) Unlock() { + u.Mutex.Unlock() + u.fn() +} + +// TestArchiveExport_ReadsOneSnapshot proves an export writes a file +// out as it was when the export opened it, and holds up no archive +// write: a row written after the export was listed but before the file +// was opened is in the export, and one written while the file is open +// is stored, and is not. A write held up for the whole busy timeout +// would fail. +func TestArchiveExport_ReadsOneSnapshot(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0)) + + opened := &unlockHook{fn: func() { + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0)) + }} + export := newExport(t, path, opened, func() (string, error) { + return path, nil + }) + + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0)) + + assert.Equal(t, + []string{"listed", "before-open"}, + exportedEventIDs(t, writeExport(t, export)), + ) + + var stored int64 + + require.NoError(t, openArchiveDBForRead(t, path). + Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error) + assert.Equal(t, int64(3), stored) +} + +// TestArchiveExport_FindsFilesAfterRename proves that renaming the +// archive after an export has listed it, as renaming its webhook or +// target does, loses no file: the export finds each file again by its +// period under the new name. A file moved away by then is skipped. +func TestArchiveExport_FindsFilesAfterRename(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "archive-old.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + + for _, period := range []string{"", dayPeriod, hourPeriod} { + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + )) + } + + current := path + export := newExport(t, path, &sync.Mutex{}, func() (string, error) { + return current, nil + }) + + require.NoError(t, w.Rename("archive-new.db")) + + current = filepath.Join(dir, "archive-new.db") + + removeArchiveFiles(t, periodPath(current, dayPeriod)) + + assert.Equal(t, + []string{"in-", "in-" + hourPeriod}, + exportedEventIDs(t, writeExport(t, export)), + ) +} + +// TestArchiveExport_EveryFileOldestFirst writes a row to a target's +// file without a period and to its files for a month, an hour and a +// day, and proves the export holds every row: the file without a +// period first, then the others oldest period first, each row from a +// file named for a period carrying that period. A file made after the +// export was listed is not in it. +func TestArchiveExport_EveryFileOldestFirst(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + + // Written in an order that is not the export's. + for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} { + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + )) + } + + export := listExport(t, path) + + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", + )) + + events := exportedEvents(t, writeExport(t, export)) + ids := make([]string, 0, len(events)) + periods := make([]any, 0, len(events)) + + for _, ev := range events { + ids = append(ids, fmt.Sprint(ev["event_id"])) + periods = append(periods, ev["period"]) + } + + assert.Equal(t, + []string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod}, + ids, + ) + assert.Equal(t, + []any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods, + ) + assert.NotContains(t, events[0], "period", + "a row from the file without a period has no period") +} + +// openFilesPeak is an io.Writer that discards what it is given and +// records the most archive files in dir the process had open at any +// write, as /proc/self/fd lists the files a process has open. +type openFilesPeak struct { + dir string + max int +} + +func (p *openFilesPeak) Write(b []byte) (int, error) { + fds, err := os.ReadDir("/proc/self/fd") + if err != nil { + return 0, err + } + + open := map[string]bool{} + + for _, fd := range fds { + file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name())) + if err == nil && filepath.Dir(file) == p.dir && + strings.HasSuffix(file, ".db") { + open[file] = true + } + } + + p.max = max(p.max, len(open)) + + return len(b), nil +} + +// TestArchiveExport_OneFileOpenAtATime exports a target with a file for +// each of 24 hours and proves the export never had more than one of +// them open, and had one open while it wrote. Each file holds a row of +// 48 KiB of random base64, which gzip shrinks little, so the export +// writes output while it reads each file. +func TestArchiveExport_OneFileOpenAtATime(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("only Linux lists a process's open files in /proc/self/fd") + } + + // Readlink gives each open file's path with no symbolic link in it. + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + path := filepath.Join(dir, "archive-wh.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + random := make([]byte, 36<<10) + + for hour := range 24 { + _, _ = rand.Read(random) + + require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{ + Body: base64.StdEncoding.EncodeToString(random), + }, 0, fmt.Sprintf("2026-10-01-%02d", hour))) + } + + // The writer's own handle on the last file is not the export's. + w.Evict() + + // Through a buffer, the open files are listed once per 8 KiB of + // output, a few times for each file, rather than at each of gzip's + // small writes, which takes far longer. + peak := &openFilesPeak{dir: dir} + buffered := bufio.NewWriterSize(peak, 8<<10) + + require.NoError(t, writeExportTo(t, listExport(t, path), buffered)) + require.NoError(t, buffered.Flush()) + assert.Equal(t, 1, peak.max) +} + +// heapPeak is an io.Writer that discards what it is given and records +// the largest heap it saw at a write. It collects garbage twice before +// each reading, so the heap it reads is what is still held. Once is not +// enough: the libraries the export calls (regexp, under GORM's table +// names, and encoding/json among them) cache buffers in a sync.Pool, +// which keeps them through one collection, so after one the reading +// counts however many happen to be cached. That varies from run to run +// by about as much as the limit in TestArchiveExport_Streams. +type heapPeak struct { + max uint64 +} + +func (p *heapPeak) Write(b []byte) (int, error) { + var m runtime.MemStats + + runtime.GC() + runtime.GC() + runtime.ReadMemStats(&m) + p.max = max(p.max, m.HeapAlloc) + + return len(b), nil +} + +// exportHeapGrowth exports an archive of rows random bodies, each +// bodySize bytes of base64, and returns how far the heap rose above +// where it stood when the export began, at its highest. +func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 { + t.Helper() + + path := filepath.Join(t.TempDir(), "archive.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + + // Base64 makes four characters of every three bytes. + random := make([]byte, bodySize/4*3) + + for range rows { + _, _ = rand.Read(random) + + require.NoError(t, w.Write(delivery.ExportArchivedEvent{ + Body: base64.StdEncoding.EncodeToString(random), + }, 0)) + } + + export := listExport(t, path) + + // Twice, for the reason heapPeak gives. + runtime.GC() + runtime.GC() + + var start runtime.MemStats + + runtime.ReadMemStats(&start) + + // Through a buffer, the heap is read once per 8 KiB of output + // rather than at each of gzip's small writes, which takes far + // longer. + peak := &heapPeak{max: start.HeapAlloc} + buffered := bufio.NewWriterSize(peak, 8<<10) + + require.NoError(t, writeExportTo(t, export, buffered)) + require.NoError(t, buffered.Flush()) + + return peak.max - start.HeapAlloc +} + +// TestArchiveExport_Streams proves an export holds neither the archive +// nor its output in memory whole: exporting 384 KiB more of archive +// raises the heap's peak by less than half of that. The export's own +// memory, mostly gzip's compressor, is the same for both archives, so +// it cancels out. The bodies are random bytes in base64, which gzip +// shrinks by only a quarter, so an export that read every row before +// writing, or built the JSON or the gzipped file before writing it, +// would raise the peak by at least three quarters of the difference. +// +// The smaller archive has two rows so that its export, too, writes +// out more than the 8 KiB buffer in exportHeapGrowth before it ends: +// the heap must be read while the export's own memory is held. +// +//nolint:paralleltest // It measures the heap, which tests share. +func TestArchiveExport_Streams(t *testing.T) { + const ( + bodySize = 16 << 10 + smallRows = 2 + largeRows = smallRows + 24 + limit = (largeRows - smallRows) * bodySize / 2 + ) + + small := exportHeapGrowth(t, smallRows, bodySize) + large := exportHeapGrowth(t, largeRows, bodySize) + + assert.Less(t, large, small+limit, + "the heap rose by %d for %d rows and by %d for %d rows", + small, smallRows, large, largeRows, + ) +} + +// TestArchiveExportFileName proves the download is named for the +// webhook and the target, with the names made safe as for the archive +// file, and the export time in UTC. +func TestArchiveExportFileName(t *testing.T) { + t.Parallel() + + cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds())) + + assert.Equal(t, + "archive-orders-eu-long-term-archive-20261002T120304Z.json.gz", + delivery.ArchiveExportFileName( + exportWebhookName, exportTargetName, + time.Date(2026, 10, 2, 14, 3, 4, 0, cest), + ), + ) +} diff --git a/internal/delivery/target_database_rotation.go b/internal/delivery/target_database_rotation.go new file mode 100644 index 0000000..e16180e --- /dev/null +++ b/internal/delivery/target_database_rotation.go @@ -0,0 +1,198 @@ +package delivery + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "sneak.berlin/go/webhooker/internal/database" +) + +// The archive rotations: how often a database target starts a new +// archive file. Every rotation but none puts the period of an event's +// receive time, in UTC, in the name of the file the event goes to, +// written in the layout beside it. +const ( + archiveRotationNone = "none" + archiveRotationMonthly = "monthly" + archiveRotationDaily = "daily" + archiveRotationHourly = "hourly" + + archiveMonthLayout = "2006-01" + archiveDayLayout = "2006-01-02" + archiveHourLayout = "2006-01-02-15" +) + +// errArchiveRotationUnknown is returned for a rotation that is not one +// of the four. +var errArchiveRotationUnknown = errors.New( + "rotation must be none, monthly, daily or hourly", +) + +// archiveFile is one of a database target's archive files, and the +// period in its name: "" for the file named without one. +type archiveFile struct { + path string + period string +} + +// ValidateArchiveRotation checks a user-supplied archive rotation for +// a database target: empty or none (both meaning one file), monthly, +// daily or hourly. +func ValidateArchiveRotation(rotation string) error { + switch rotation { + case "", archiveRotationNone, archiveRotationMonthly, + archiveRotationDaily, archiveRotationHourly: + return nil + default: + return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation) + } +} + +// parseArchiveRotation reads the rotation from a database target's +// config JSON. An empty config or an empty rotation is none. +func parseArchiveRotation(configJSON string) (string, error) { + if configJSON == "" { + return archiveRotationNone, nil + } + + var cfg databaseTargetConfig + + err := json.Unmarshal([]byte(configJSON), &cfg) + if err != nil { + return "", fmt.Errorf("parsing database target config: %w", err) + } + + err = ValidateArchiveRotation(cfg.Rotation) + if err != nil { + return "", err + } + + if cfg.Rotation == "" { + return archiveRotationNone, nil + } + + return cfg.Rotation, nil +} + +// archivePeriod returns the period, in UTC, that a rotation puts an +// event received at receivedAt in: "2026-10" for monthly, +// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none. +func archivePeriod(rotation string, receivedAt time.Time) string { + switch rotation { + case archiveRotationMonthly: + return receivedAt.UTC().Format(archiveMonthLayout) + case archiveRotationDaily: + return receivedAt.UTC().Format(archiveDayLayout) + case archiveRotationHourly: + return receivedAt.UTC().Format(archiveHourLayout) + default: + return "" + } +} + +// archivePeriodPath returns the path of a database target's archive +// file for a period: path, as ArchivePath gives it, with "-" and the +// period put before its ".db". The period "" gives path itself. +func archivePeriodPath(path, period string) string { + if period == "" { + return path + } + + return strings.TrimSuffix(path, ".db") + "-" + period + ".db" +} + +// ArchivePathAt returns the archive file a database target writes an +// event received at receivedAt to: ArchivePath's file, with the +// period in its name when the target rotates. +func ArchivePathAt( + dbMgr *database.WebhookDBManager, + webhook *database.Webhook, + target *database.Target, + receivedAt time.Time, +) (string, error) { + rotation, err := parseArchiveRotation(target.Config) + if err != nil { + return "", err + } + + return archivePeriodPath( + ArchivePath(dbMgr, webhook, target), + archivePeriod(rotation, receivedAt), + ), nil +} + +// archiveFiles lists the database target's archive files that exist, +// given the path ArchivePath gives it: the file at path, then each +// file archivePeriodPath names for path and a period, oldest period +// first. Which rotation wrote a file does not matter, so the files of +// an earlier rotation setting are listed too. +func archiveFiles(path string) ([]archiveFile, error) { + dir := filepath.Dir(path) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("listing archive files: %w", err) + } + + stem := strings.TrimSuffix(filepath.Base(path), ".db") + + var files []archiveFile + + for _, entry := range entries { + period, ok := archiveFilePeriod(stem, entry.Name()) + if ok { + files = append(files, archiveFile{ + path: filepath.Join(dir, entry.Name()), + period: period, + }) + } + } + + // A month sorts before the days and hours in it. + slices.SortFunc(files, func(a, b archiveFile) int { + return strings.Compare(a.period, b.period) + }) + + return files, nil +} + +// archiveFilePeriod reports whether name is the name of an archive +// file of the target whose file name without a period is stem+".db", +// and the period in it. +func archiveFilePeriod(stem, name string) (string, bool) { + rest, ok := strings.CutPrefix(name, stem) + if !ok { + return "", false + } + + rest, ok = strings.CutSuffix(rest, ".db") + if !ok { + return "", false + } + + if rest == "" { + return "", true + } + + period, ok := strings.CutPrefix(rest, "-") + if !ok { + return "", false + } + + for _, layout := range []string{ + archiveMonthLayout, archiveDayLayout, archiveHourLayout, + } { + _, err := time.Parse(layout, period) + if err == nil { + return period, true + } + } + + return "", false +} diff --git a/internal/delivery/target_database_rotation_test.go b/internal/delivery/target_database_rotation_test.go new file mode 100644 index 0000000..667a23b --- /dev/null +++ b/internal/delivery/target_database_rotation_test.go @@ -0,0 +1,390 @@ +package delivery_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// The archive rotations, and the configs of a daily target and of one +// whose rotation is not one of the four. +const ( + rotationNone = "none" + rotationMonthly = "monthly" + rotationDaily = "daily" + rotationHourly = "hourly" + + dailyConfig = `{"rotation":"daily"}` + weeklyConfig = `{"rotation":"weekly"}` +) + +// The periods the tests archive into most: two days, and an hour of +// the first. +const ( + dayPeriod = "2026-03-04" + nextDayPeriod = "2026-03-05" + hourPeriod = "2026-03-04-05" +) + +// periodPath returns the archive file for a period of the target whose +// file without a period is path. +func periodPath(path, period string) string { + return strings.TrimSuffix(path, ".db") + "-" + period + ".db" +} + +// deliverReceivedAt delivers to a database target an event whose +// receive time is receivedAt, and returns the event's id. The receive +// time is what decides a rotated archive's file, so setting it is how +// these tests move the clock across a period boundary. +func (env *archiveEnv) deliverReceivedAt( + t *testing.T, tgt *database.Target, receivedAt time.Time, +) string { + t.Helper() + + webhookDB := testWebhookDB(t) + event := seedEvent(t, webhookDB, `{"n":1}`) + event.CreatedAt = receivedAt + + env.eng.ExportDeliverDatabase( + webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), + ) + + return event.ID +} + +// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each +// rotation, an event received in the last second of a period and one +// received in the first second of the next, and checks each lands in +// the file named for its own period, in UTC. Rotation none keeps both +// in the one file. +func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) { + t.Parallel() + + berlin := time.FixedZone("CEST", 2*60*60) + + cases := []struct { + name string + rotation string + before, after time.Time + // periods are the periods of before and after. + periods [2]string + }{ + { + rotationMonthly, rotationMonthly, + time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC), + time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + [2]string{"2026-01", "2026-02"}, + }, + { + rotationDaily, rotationDaily, + time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC), + time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC), + [2]string{dayPeriod, nextDayPeriod}, + }, + { + // The same instants, received in a zone two hours ahead + // of UTC, where they fall on 5 March: the period is UTC's. + "daily in another zone", rotationDaily, + time.Date(2026, 3, 5, 1, 59, 59, 0, berlin), + time.Date(2026, 3, 5, 2, 0, 0, 0, berlin), + [2]string{dayPeriod, nextDayPeriod}, + }, + { + rotationHourly, rotationHourly, + time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC), + time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC), + [2]string{hourPeriod, "2026-03-04-06"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`) + path := env.archivePath(tgt) + + first := env.deliverReceivedAt(t, tgt, tc.before) + second := env.deliverReceivedAt(t, tgt, tc.after) + + assert.Equal(t, []string{first}, + archivedEventIDs(t, periodPath(path, tc.periods[0]))) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(path, tc.periods[1]))) + assert.NoFileExists(t, path, + "a rotated target never writes the file without a period") + }) + } + + t.Run(rotationNone, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`) + + first := env.deliverReceivedAt(t, tgt, cases[0].before) + second := env.deliverReceivedAt(t, tgt, cases[0].after) + + assert.ElementsMatch(t, []string{first, second}, + archivedEventIDs(t, env.archivePath(tgt))) + }) +} + +// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose +// receive time is not known goes to the file for the time it is +// archived, rather than to one for the year 1. +func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`) + path := env.archivePath(tgt) + + before := time.Now().UTC().Format("2006-01") + id := env.deliverReceivedAt(t, tgt, time.Time{}) + + file := periodPath(path, time.Now().UTC().Format("2006-01")) + + _, err := os.Stat(file) + if err != nil { + // The month turned during the delivery. + file = periodPath(path, before) + } + + assert.Equal(t, []string{id}, archivedEventIDs(t, file)) + assert.NoFileExists(t, periodPath(path, "0001-01")) +} + +// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's +// rotation from none to daily between two events, and checks the +// second goes to the daily file while the first stays where it was. +func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + path := env.archivePath(tgt) + at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + first := env.deliverReceivedAt(t, tgt, at) + + tgt.Config = dailyConfig + second := env.deliverReceivedAt(t, tgt, at) + + assert.Equal(t, []string{first}, archivedEventIDs(t, path)) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(path, dayPeriod))) +} + +// TestArchiveSweep_PrunesEveryFile gives a daily target three files: +// the file without a period, left from before it rotated, and two +// daily files. Each holds a row older than the expiry, and one daily +// file also a newer row. The sweep prunes the old row from every file, +// deletes the daily file it leaves empty, and keeps the file without a +// period although it is empty too. +func TestArchiveSweep_PrunesEveryFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget( + t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`, + ) + path := env.archivePath(tgt) + emptied := periodPath(path, dayPeriod) + kept := periodPath(path, nextDayPeriod) + + now := time.Now() + old := now.Add(-48 * time.Hour) + + seedArchiveFile(t, path, tgt.WebhookID, old) + seedArchiveFile(t, emptied, tgt.WebhookID, old) + seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute)) + + env.sweeper.ExportSweep(t.Context()) + + assert.Empty(t, archivedEventIDs(t, path)) + assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept)) + + for _, suffix := range archiveFileSuffixes() { + assert.NoFileExists(t, emptied+suffix) + } +} + +// TestRename_MovesEveryFile renames a daily target that also has a +// file without a period, and checks every file moves to the new name +// with its period, rows and all, and that a later write uses the new +// name. +func TestRename_MovesEveryFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + unrotated := env.deliverReceivedAt(t, tgt, day) + + tgt.Config = dailyConfig + first := env.deliverReceivedAt(t, tgt, day) + second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) + + require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term")) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + + for _, old := range []string{ + oldPath, + periodPath(oldPath, dayPeriod), + periodPath(oldPath, nextDayPeriod), + } { + assert.NoFileExists(t, old) + } + + assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath)) + assert.Equal(t, []string{first}, + archivedEventIDs(t, periodPath(newPath, dayPeriod))) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(newPath, nextDayPeriod))) + + third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour)) + assert.Equal(t, []string{third}, + archivedEventIDs(t, periodPath(newPath, "2026-03-06"))) +} + +// TestRename_NeverReplacesARotatedFile plants a file at the new name +// of a target's daily file, and proves the rename is refused and moves +// none of the target's files. +func TestRename_NeverReplacesARotatedFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, dailyConfig) + oldPath := env.archivePath(tgt) + day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + env.deliverReceivedAt(t, tgt, day) + env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + planted := periodPath(newPath, nextDayPeriod) + require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) + + require.ErrorIs( + t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), + delivery.ErrArchiveNameTaken, + ) + + assert.FileExists(t, periodPath(oldPath, dayPeriod)) + assert.FileExists(t, periodPath(oldPath, nextDayPeriod)) + assert.NoFileExists(t, periodPath(newPath, dayPeriod)) +} + +// TestStatArchive_EveryFile proves StatArchive counts and adds up every +// one of a target's files, takes the latest write of any of them, and +// leaves out files whose names only look like the target's. +func TestStatArchive_EveryFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "archive-wh.db") + files := []string{ + path, periodPath(path, "2026-03"), periodPath(path, hourPeriod), + } + written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + + var size int64 + + for i, file := range files { + require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600)) + + size += int64(100 * (i + 1)) + at := written.Add(-time.Duration(i) * time.Hour) + require.NoError(t, os.Chtimes(file, at, at)) + } + + for _, other := range []string{ + "archive-wh-2026-13.db", "archive-wh-2026-3.db", + "archive-wh-other.db", "archive-wh-2026-03.json", + "archive-whx.db", + } { + require.NoError(t, + os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) + } + + got, err := delivery.StatArchive(path) + require.NoError(t, err) + assert.Equal(t, len(files), got.Files) + assert.Equal(t, size, got.Size) + assert.True(t, written.Equal(got.Written), got.Written) +} + +// TestArchivePathAt names the file each rotation writes an event to. +func TestArchivePathAt(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + dbMgr := databasetest.NewWebhookDBManager(t, dataDir) + webhook := &database.Webhook{ + BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders", + } + at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC) + + cases := map[string]string{ + "": "", + `{"rotation":"` + rotationNone + `"}`: "", + `{"rotation":"` + rotationMonthly + `"}`: "-2026-10", + dailyConfig: "-2026-10-01", + `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19", + } + + for config, period := range cases { + target := &database.Target{ + BaseModel: database.BaseModel{ID: "tgt-id"}, + Name: "Archive", + Config: config, + } + + got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at) + require.NoError(t, err, config) + assert.Equal(t, + filepath.Join( + dataDir, "archive-orders-archive-tgt-id"+period+".db", + ), + got, config, + ) + } + + _, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{ + Config: weeklyConfig, + }, at) + require.Error(t, err) +} + +// TestValidateArchiveRotation accepts the four rotations, and empty, +// and refuses anything else. +func TestValidateArchiveRotation(t *testing.T) { + t.Parallel() + + for _, ok := range []string{ + "", rotationNone, rotationMonthly, rotationDaily, rotationHourly, + } { + require.NoError(t, delivery.ValidateArchiveRotation(ok), ok) + } + + for _, bad := range []string{"weekly", "Daily", "hourly "} { + require.Error(t, delivery.ValidateArchiveRotation(bad), bad) + } +} diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index dc2e65c..7c96842 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -3,14 +3,14 @@ package delivery_test import ( "database/sql" "fmt" + "io/fs" "log/slog" - "net/http" "os" "path/filepath" + "strings" "testing" "time" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -18,6 +18,7 @@ import ( _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/gormlog" ) func archiveTestLogger() *slog.Logger { @@ -43,7 +44,8 @@ func openArchiveDBForRead( t.Cleanup(func() { _ = sqlDB.Close() }) gdb, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, ) require.NoError(t, err) @@ -74,25 +76,19 @@ func removeArchiveFiles(t *testing.T, path string) { // TestDeliverDatabase_ArchivesEvent verifies that delivering to // a database target marks the delivery delivered and archives -// the full event into a separate per-webhook archive file. +// the full event into the target's own archive file. func TestDeliverDatabase_ArchivesEvent(t *testing.T) { t.Parallel() - dataDir := t.TempDir() - dbMgr := database.NewTestWebhookDBManager(dataDir) - - e := delivery.NewTestEngineWithDB( - nil, dbMgr, - archiveTestLogger(), - &http.Client{Timeout: 5 * time.Second}, - 1, - ) + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, "") + event.RawQuery = eventQuery + d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) - e.ExportDeliverDatabase(webhookDB, d) + env.eng.ExportDeliverDatabase(webhookDB, d) var updated database.Delivery @@ -105,8 +101,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { ) archivePath := filepath.Join( - dataDir, - fmt.Sprintf("archive-%s.db", event.WebhookID), + env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db", ) assert.FileExists(t, archivePath) @@ -119,6 +114,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.Method, rows[0].Method) + assert.Equal(t, eventQuery, rows[0].RawQuery) assert.JSONEq(t, `{"archived":true}`, rows[0].Body) } @@ -154,6 +150,13 @@ func TestArchiveWriter_WritesRow(t *testing.T) { assert.Equal(t, row.ContentType, got[0].ContentType) assert.JSONEq(t, `{"hello":"world"}`, got[0].Body) assert.False(t, got[0].ArchivedAt.IsZero()) + + var archivedAt string + + require.NoError(t, w.DB().Raw( + "SELECT CAST(archived_at AS TEXT) FROM archived_events", + ).Row().Scan(&archivedAt)) + assertStoredTime(t, archivedAt) } func TestArchiveWriter_RecreatesAfterRemoval( @@ -190,16 +193,67 @@ func TestArchiveWriter_RecreatesAfterRemoval( assert.Equal(t, "b", got[0].EventID) } +// TestStatArchive proves StatArchive finds no file before the first +// write; after a write still held in the -wal, counts the -wal in the +// size and takes its later time as the last write; and finds no file +// again once the file has been moved away. +func TestStatArchive(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + + _, err := delivery.StatArchive(path) + require.ErrorIs(t, err, fs.ErrNotExist) + + // With the clock stopped, the reopen debounce never passes, so + // the handle stays open after the write. + stopped := time.Now() + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w.SetNow(func() time.Time { return stopped }) + + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0)) + + written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + earlier := written.Add(-time.Hour) + require.NoError(t, os.Chtimes(path, earlier, earlier)) + require.NoError(t, os.Chtimes(path+"-wal", written, written)) + + file, err := os.Stat(path) + require.NoError(t, err) + + wal, err := os.Stat(path + "-wal") + require.NoError(t, err) + require.Positive(t, wal.Size()) + + got, err := delivery.StatArchive(path) + require.NoError(t, err) + assert.Equal(t, 1, got.Files) + assert.Equal(t, file.Size()+wal.Size(), got.Size) + assert.True(t, written.Equal(got.Written), got.Written) + + removeArchiveFiles(t, path) + + _, err = delivery.StatArchive(path) + require.ErrorIs(t, err, fs.ErrNotExist) +} + func TestArchiveWriter_ReopenDebounce(t *testing.T) { t.Parallel() - // A generous debounce keeps the two rapid writes inside - // the window even on a heavily loaded test machine. + const debounce = 2 * time.Second + path := filepath.Join(t.TempDir(), "archive-wh.db") w := delivery.NewExportArchiveWriter( - path, archiveTestLogger(), 2*time.Second, + path, archiveTestLogger(), debounce, ) + // The writer measures its reopen debounce on this clock, which + // only the test moves, so how long the host takes between + // writes cannot change the result. + now := time.Now() + + w.SetNow(func() time.Time { return now }) + require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "a"}, 0, )) @@ -211,7 +265,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) { // initial open — no extra close/reopen. assert.Equal(t, 1, w.Reopens()) - time.Sleep(2100 * time.Millisecond) + now = now.Add(debounce) require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "c"}, 0, @@ -288,31 +342,31 @@ func TestParseArchiveExpiry(t *testing.T) { } } -// seedDatabaseTargetDelivery seeds a pending delivery for a -// database target with the given config JSON and returns the -// in-memory delivery the target handler is invoked with. +// seedDatabaseTargetDelivery seeds a pending delivery of an event +// to a database target and returns the in-memory delivery the +// target handler is invoked with. func seedDatabaseTargetDelivery( t *testing.T, webhookDB *gorm.DB, event database.Event, - config string, + tgt *database.Target, ) *database.Delivery { t.Helper() dlv := seedDelivery( - t, webhookDB, event.ID, uuid.New().String(), + t, webhookDB, event.ID, tgt.ID, database.DeliveryStatusPending, ) d := &database.Delivery{ EventID: event.ID, - TargetID: dlv.TargetID, + TargetID: tgt.ID, Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: "test-db", + Name: tgt.Name, Type: database.TargetTypeDatabase, - Config: config, + Config: tgt.Config, }, } d.ID = dlv.ID @@ -330,22 +384,14 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery( ) { t.Parallel() - dataDir := t.TempDir() - - e := delivery.NewTestEngineWithDB( - nil, database.NewTestWebhookDBManager(dataDir), - archiveTestLogger(), - &http.Client{Timeout: 5 * time.Second}, - 1, - ) + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":false}`) - d := seedDatabaseTargetDelivery( - t, webhookDB, event, `{"expiry":"nonsense"}`, - ) + d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) - e.ExportDeliverDatabase(webhookDB, d) + env.eng.ExportDeliverDatabase(webhookDB, d) var updated database.Delivery @@ -373,10 +419,7 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery( ) assert.NoFileExists(t, - filepath.Join( - dataDir, - fmt.Sprintf("archive-%s.db", event.WebhookID), - ), + env.archivePath(tgt), "no archive file should exist for a failed config", ) } @@ -400,3 +443,328 @@ func TestValidateArchiveExpiry(t *testing.T) { ) } } + +// TestArchiveFileName pins the archive file name and the rules +// that make a webhook or target name safe to put in it. +func TestArchiveFileName(t *testing.T) { + t.Parallel() + + const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f" + + cases := []struct { + name string + webhook string + target string + want string + }{ + { + "plain names", "orders", "archive", + "archive-orders-archive-" + id + ".db", + }, + { + "lowercased", "Orders", "Main Archive", + "archive-orders-main-archive-" + id + ".db", + }, + { + "a run of other characters is one dash", + `a /\..b`, "c__--d", + "archive-a-b-c-d-" + id + ".db", + }, + { + "no dash at either end", " --orders!! ", "(archive)", + "archive-orders-archive-" + id + ".db", + }, + { + "path separators", "../../etc/passwd", "a/b", + "archive-etc-passwd-a-b-" + id + ".db", + }, + { + "letters outside ASCII are dropped", + "Bestellungen Größe", "café", + "archive-bestellungen-gr-e-caf-" + id + ".db", + }, + { + "nothing left is unnamed", "", "!!!", + "archive-unnamed-unnamed-" + id + ".db", + }, + { + "cut to 40 characters", strings.Repeat("a", 50), "x", + "archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db", + }, + { + "no dash left by the cut", + strings.Repeat("a", 39) + " b", "x", + "archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, tc.want, + delivery.ArchiveFileName(tc.webhook, tc.target, id), + ) + }) + } +} + +// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two +// database targets of one webhook archive into separate files. +func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + first := env.seedDatabaseTarget(t, "") + second := env.addDatabaseTarget(t, first.WebhookID, "") + + webhookDB := testWebhookDB(t) + event := seedEvent(t, webhookDB, `{"n":1}`) + + for _, tgt := range []*database.Target{first, second} { + env.eng.ExportDeliverDatabase( + webhookDB, + seedDatabaseTargetDelivery(t, webhookDB, event, tgt), + ) + } + + require.NotEqual( + t, env.archivePath(first), env.archivePath(second), + ) + assert.Equal( + t, []string{event.ID}, + archivedEventIDs(t, env.archivePath(first)), + ) + assert.Equal( + t, []string{event.ID}, + archivedEventIDs(t, env.archivePath(second)), + ) +} + +// TestRename_MovesTheFile proves a rename moves the archive, rows +// and all, and that later writes go to the new name. +func TestRename_MovesTheFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + + webhookDB := testWebhookDB(t) + first := seedEvent(t, webhookDB, `{"n":1}`) + env.eng.ExportDeliverDatabase( + webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt), + ) + require.FileExists(t, oldPath) + + require.NoError( + t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), + ) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + + assert.NoFileExists(t, oldPath) + assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath)) + + second := seedEvent(t, webhookDB, `{"n":2}`) + env.eng.ExportDeliverDatabase( + webhookDB, + seedDatabaseTargetDelivery(t, webhookDB, second, tgt), + ) + + assert.ElementsMatch( + t, []string{first.ID, second.ID}, + archivedEventIDs(t, newPath), + ) + assert.NoFileExists( + t, oldPath, "a write after the rename must use the new name", + ) +} + +// TestRename_NeverReplacesAFile plants a file at the new name, once +// the .db alone, once a lone -wal and once a lone -shm, and proves +// each time that the rename is refused, the planted file survives, +// and the archive keeps its name and its rows. +func TestRename_NeverReplacesAFile(t *testing.T) { + t.Parallel() + + for _, suffix := range archiveFileSuffixes() { + t.Run("planted .db"+suffix, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + + webhookDB := testWebhookDB(t) + first := seedEvent(t, webhookDB, `{"n":1}`) + env.eng.ExportDeliverDatabase( + webhookDB, + seedDatabaseTargetDelivery(t, webhookDB, first, tgt), + ) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + plantedPath := newPath + suffix + require.NoError( + t, os.WriteFile(plantedPath, []byte("planted"), 0o600), + ) + + require.ErrorIs( + t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), + delivery.ErrArchiveNameTaken, + ) + + //nolint:gosec // reads the file the test planted under t.TempDir() + planted, err := os.ReadFile(plantedPath) + require.NoError(t, err) + assert.Equal(t, "planted", string(planted)) + + second := seedEvent(t, webhookDB, `{"n":2}`) + env.eng.ExportDeliverDatabase( + webhookDB, + seedDatabaseTargetDelivery(t, webhookDB, second, tgt), + ) + + assert.ElementsMatch( + t, []string{first.ID, second.ID}, + archivedEventIDs(t, oldPath), + ) + }) + } +} + +// TestRename_BeforeTheNameIsSaved covers the order the handlers +// use: they rename before they save the new name, so a delivery in +// between must write under the new name although the main database +// still has the old one. It also shows that renaming an archive that +// does not exist yet is not an error. +func TestRename_BeforeTheNameIsSaved(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + + require.NoError( + t, env.eng.Rename(tgt.ID, "Orders", "Archive"), + ) + + webhookDB := testWebhookDB(t) + event := seedEvent(t, webhookDB, `{"n":1}`) + env.eng.ExportDeliverDatabase( + webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), + ) + + assert.FileExists( + t, + filepath.Join( + env.dataDir, "archive-orders-archive-"+tgt.ID+".db", + ), + ) + assert.NoFileExists(t, env.archivePath(tgt)) +} + +// TestArchiveWriter_RenameMovesSidecars proves a rename carries +// the -wal and -shm a crash can leave beside an archive no handle +// has opened since. SQLite finds them by name, so a -wal left +// behind would lose the transactions it holds. +func TestArchiveWriter_RenameMovesSidecars(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + newPath := filepath.Join(dir, "archive-new.db") + + for _, suffix := range archiveFileSuffixes() { + require.NoError( + t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), + ) + } + + w := delivery.NewExportArchiveWriter( + oldPath, archiveTestLogger(), 0, + ) + + require.NoError(t, w.Rename("archive-new.db")) + + for _, suffix := range archiveFileSuffixes() { + assert.NoFileExists(t, oldPath+suffix) + assert.FileExists(t, newPath+suffix) + } + + assert.Equal(t, newPath, w.Path()) +} + +// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to +// move after the .db has moved, and proves the .db is moved back, so +// the archive is never split across two names. The new name is 255 +// bytes, the longest a file name may be, so the .db can take it but +// the -wal, four bytes longer, cannot. +func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + newName := strings.Repeat("a", 252) + ".db" + + for _, suffix := range archiveFileSuffixes() { + require.NoError( + t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), + ) + } + + w := delivery.NewExportArchiveWriter( + oldPath, archiveTestLogger(), 0, + ) + + require.Error(t, w.Rename(newName)) + + for _, suffix := range archiveFileSuffixes() { + assert.FileExists(t, oldPath+suffix) + } + + assert.NoFileExists(t, filepath.Join(dir, newName)) + assert.Equal(t, oldPath, w.Path()) +} + +// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a +// file without a period and a file for a month, and proves that when +// the month's file fails to move, the file already moved is moved back: +// both files are under the old name with their rows, and nothing is +// under the new name. The new name is 251 bytes, so the file without a +// period, with its -wal and -shm, can take it, but the month's file, +// eight bytes longer, cannot. +func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + monthPath := filepath.Join(dir, "archive-old-2026-03.db") + w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0) + + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "", + )) + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03", + )) + + require.Error(t, w.Rename(strings.Repeat("a", 248)+".db")) + + assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath)) + assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"), + "%s is not under the old name", entry.Name()) + } + + assert.Equal(t, oldPath, w.Path()) +} diff --git a/internal/delivery/target_headers.go b/internal/delivery/target_headers.go index 20e3c9b..ca2b069 100644 --- a/internal/delivery/target_headers.go +++ b/internal/delivery/target_headers.go @@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool { // one pair silently overwriting the other. // // An input with no pairs yields an empty map, which omitempty drops -// from the stored config: a target configured with no headers keeps -// the same config JSON it had before this field existed. +// from the stored config, so a target configured with no headers +// stores no headers key at all. func ParseTargetHeaders(raw string) (map[string]string, error) { headers := make(map[string]string) diff --git a/internal/delivery/target_headers_test.go b/internal/delivery/target_headers_test.go index 3237132..4250dee 100644 --- a/internal/delivery/target_headers_test.go +++ b/internal/delivery/target_headers_test.go @@ -204,10 +204,11 @@ func TestNewTargetConfigForm(t *testing.T) { form, err = delivery.NewTargetConfigForm(&database.Target{ Type: database.TargetTypeDatabase, - Config: `{"expiry":"720h"}`, + Config: `{"expiry":"720h","rotation":"daily"}`, }) require.NoError(t, err) assert.Equal(t, "720h", form.Expiry) + assert.Equal(t, rotationDaily, form.Rotation) form, err = delivery.NewTargetConfigForm(&database.Target{ Type: database.TargetTypeLog, @@ -216,8 +217,9 @@ func TestNewTargetConfigForm(t *testing.T) { assert.Empty(t, form.URL) } -// A keep-forever archive target must pre-fill as an empty field, so -// saving the form back unchanged stores the same empty config. +// A keep-forever archive target yields an empty expiry, so the edit +// form starts on never; saving it unchanged stores never, which means +// the same as an empty expiry. func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { t.Parallel() @@ -247,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) { Type: database.TargetTypeDatabase, Config: `{"expiry":"soon"}`, }, + { + Type: database.TargetTypeDatabase, + Config: weeklyConfig, + }, {Type: database.TargetType("nope")}, } diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 9c7b539..8733f31 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "sync" "time" @@ -32,6 +33,11 @@ type HTTPTargetConfig struct { URL string `json:"url"` Headers map[string]string `json:"headers,omitempty"` Timeout int `json:"timeout,omitempty"` + + // ForwardQuery passes each event's query string on to the target, + // appended to URL. Off, the target URL is sent exactly as + // configured. + ForwardQuery bool `json:"forwardQuery,omitempty"` } // httpCore holds the retry, backoff, and circuit-breaker @@ -234,7 +240,7 @@ func (c *httpCore) handleRetry( database.DeliveryStatusRetrying, ) - backoff := calcBackoff(attemptNum) + backoff := Backoff(attemptNum) retryTask := *task retryTask.AttemptNum = attemptNum + 1 @@ -301,7 +307,7 @@ func (c *httpCore) remainingBackoff( return 0 } - backoff := calcBackoff(attemptNum) + backoff := Backoff(attemptNum) elapsed := time.Since(lastResult.CreatedAt) remaining := backoff - elapsed @@ -326,12 +332,14 @@ func (c *httpCore) backoffElapsed( return true } - backoff := calcBackoff(attemptNum) + backoff := Backoff(attemptNum) return time.Since(lastResult.CreatedAt) >= backoff } -func calcBackoff(attemptNum int) time.Duration { +// Backoff is how long an http or slack target with retries waits after +// a delivery's failed attempt attemptNum before trying it again. +func Backoff(attemptNum int) time.Duration { shift := max(attemptNum-1, 0) shift = min(shift, maxBackoffShift) @@ -442,7 +450,13 @@ func (t *httpTarget) doHTTPRequest( ) } - originScoped := applyRequestHeaders(req, event, cfg) + if cfg.ForwardQuery { + appendQuery(req.URL, event.RawQuery) + } + + originScoped := applyRequestHeaders( + req, event, cfg, t.eng.userAgent(), + ) client := t.clientForRequest(cfg, originScoped) @@ -470,6 +484,19 @@ func (t *httpTarget) doHTTPRequest( return resp.StatusCode, string(body), dur, nil } +// appendQuery adds an event's query string to a delivery's URL, joined +// with "&" to any query string the target URL already has. +func appendQuery(u *url.URL, rawQuery string) { + switch { + case rawQuery == "": + return + case u.RawQuery == "": + u.RawQuery = rawQuery + default: + u.RawQuery += "&" + rawQuery + } +} + // clientForRequest returns the client for one delivery attempt. // originScoped is the header set applyRequestHeaders built for that // attempt; a request with neither a per-target timeout nor an @@ -562,10 +589,13 @@ func isForwardableHeader(name string) bool { // Content-Type goes out once: a Content-Type configured on the target // wins, otherwise the event's ContentType, otherwise none. The inbound // Content-Type in the event's headers is never forwarded. +// +// userAgent is set last, over any configured or inbound User-Agent. func applyRequestHeaders( req *http.Request, event *database.Event, cfg *HTTPTargetConfig, + userAgent string, ) []string { if event.ContentType != "" { req.Header.Set( @@ -580,7 +610,7 @@ func applyRequestHeaders( originScoped[http.CanonicalHeaderKey(k)] = struct{}{} } - req.Header.Set("User-Agent", "webhooker/1.0") + req.Header.Set("User-Agent", userAgent) // A Content-Type configured on the target describes the body // being sent rather than the sender. A 307/308 preserves the diff --git a/internal/delivery/target_http_query_test.go b/internal/delivery/target_http_query_test.go new file mode 100644 index 0000000..fb3a5de --- /dev/null +++ b/internal/delivery/target_http_query_test.go @@ -0,0 +1,172 @@ +package delivery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// eventQuery is the query string the events in these tests arrived +// with. +const eventQuery = "a=1&b=2" + +// httpTargetConfig is the stored configuration of an HTTP target at +// targetURL. +func httpTargetConfig( + t *testing.T, targetURL string, forwardQuery bool, +) string { + t.Helper() + + cfg, err := json.Marshal(delivery.HTTPTargetConfig{ + URL: targetURL, ForwardQuery: forwardQuery, + }) + require.NoError(t, err) + + return string(cfg) +} + +// deliverWithQuery sends one event that arrived with eventQuery to an +// HTTP target configured with cfg, through the path a received event's +// delivery takes, and returns the attempt it recorded. +func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { + t.Helper() + + s := newISetup(t) + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}") + d := iSeedDelivery( + t, s.WebhookDB, event.ID, uuid.NewString(), + database.DeliveryStatusPending, + ) + task := iTask( + d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body, + ) + task.RawQuery = eventQuery + + s.Engine.ExportProcessNewTask(context.TODO(), &task) + + var result database.DeliveryResult + + require.NoError(t, s.WebhookDB.Where( + "delivery_id = ?", d.ID, + ).First(&result).Error) + + return result +} + +// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to: +// with the target's setting off, the target URL exactly as configured; +// with it on, the event's query string appended, joined with "&" to a +// query string the target URL already has. +func TestDeliverHTTP_ForwardQuery(t *testing.T) { + t.Parallel() + + // The target URL's path, without and with a query string of its + // own. + const ( + plain = "/in" + withQuery = "/in?key=k" + ) + + tests := map[string]struct { + path string + forwardQuery bool + want string + }{ + "off": { + path: plain, want: plain, + }, + "off, the target URL has a query string": { + path: withQuery, want: withQuery, + }, + "on": { + path: plain, forwardQuery: true, want: plain + "?" + eventQuery, + }, + "on, the target URL has a query string": { + path: withQuery, forwardQuery: true, + want: withQuery + "&" + eventQuery, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusOK) + }, + )) + t.Cleanup(ts.Close) + + result := deliverWithQuery(t, httpTargetConfig( + t, ts.URL+tc.path, tc.forwardQuery, + )) + + assert.True(t, result.Success) + require.Len(t, received, 1) + assert.Equal(t, tc.want, <-received) + }) + } +} + +// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the +// credential in a target URL's own query string stays masked once the +// event's query string is appended to it: in a response or error that +// echoes the URL the target was sent, as the event log's Redactor shows +// it, and in the error a failed connection stores. +func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t" + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusBadRequest) + }, + )) + t.Cleanup(ts.Close) + + target := &database.Target{ + Type: database.TargetTypeHTTP, + Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), + } + + deliverWithQuery(t, target.Config) + require.Len(t, received, 1) + + sent := <-received + require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent) + + redactor := delivery.NewRedactor(target) + + for _, echoed := range []string{sent, ts.URL + sent} { + shown := redactor.Redact("rejected " + echoed) + assert.NotContains(t, shown, secret, echoed) + assert.Contains(t, shown, delivery.RedactionMarker, echoed) + } + + // Nothing listens on port 1. + failed := deliverWithQuery(t, httpTargetConfig( + t, "http://127.0.0.1:1/in?token="+secret, true, + )) + require.NotEmpty(t, failed.Error) + assert.NotContains(t, failed.Error, secret) + assert.NotContains(t, failed.Error, eventQuery) +} diff --git a/internal/delivery/target_log.go b/internal/delivery/target_log.go index 096a5c7..9a13229 100644 --- a/internal/delivery/target_log.go +++ b/internal/delivery/target_log.go @@ -9,9 +9,9 @@ import ( ) // logTarget is a fire-and-forget target that logs the entire -// inbound webhook — the full request body and headers, plus -// the method, content type, and the webhook and entrypoint -// ids — then records a single successful attempt. +// inbound webhook — the full request body, query string and +// headers, plus the method, content type, and the webhook and +// entrypoint ids — then records a single successful attempt. // // This is the one log call in the service that deliberately writes // unbounded client-chosen bytes, so it is the one exception to the @@ -46,6 +46,7 @@ func (t *logTarget) Deliver( "webhook_id", d.Event.WebhookID, "entrypoint_id", d.Event.EntrypointID, "method", d.Event.Method, + "raw_query", d.Event.RawQuery, "content_type", d.Event.ContentType, "headers", d.Event.Headers, "body", d.Event.Body, diff --git a/internal/delivery/target_redact.go b/internal/delivery/target_redact.go index a9050db..31d4867 100644 --- a/internal/delivery/target_redact.go +++ b/internal/delivery/target_redact.go @@ -13,9 +13,9 @@ import ( const RedactionMarker = "(redacted)" // Redactor removes one target's own credential material from -// text that target's remote peer chose: a delivery response -// body, or a delivery error stored before the delivery path -// learned to mask the URLs it embeds. +// a delivery response body, whose text the target's remote +// peer chose, and from a stored delivery error as a second +// line behind the delivery path's own URL masking. // // It removes byte-identical echoes of strings taken from the // target's stored configuration, and nothing else. Anything @@ -162,18 +162,22 @@ func targetSecrets(t *database.Target) []string { } // urlSecrets returns the substrings of a destination URL that -// must not survive into a rendered page: the whole URL, the -// parts of it MaskURL elides, and any userinfo. +// must not survive into a rendered page: the whole URL; its +// path, unless that is empty or "/"; its query string, and the +// request URI that carries it, which a remote echoing the +// request line shows even when the URL has no path; and its +// userinfo and password. // -// No length floor is applied to the path, and none to the -// userinfo. A short path or a four-byte username is treated as -// a credential exactly like a long one, because the field takes -// an arbitrary URL and no part of it can be assumed non-secret — -// the same rule MaskURL applies. headerSecrets does carry a -// floor, and the difference is deliberate: a header is picked -// out by a name-shaped guess and its value may be ordinary -// text, whereas a URL's path and userinfo are credential -// material by position. +// No length floor is applied to the path, the query string or +// the userinfo. A short path or a four-byte username is +// treated as a credential exactly like a long one, because the +// field takes an arbitrary URL and no part of it can be +// assumed non-secret — the same rule MaskURL applies. +// headerSecrets does carry a floor, and the difference is +// deliberate: a header is picked out by a name-shaped guess +// and its value may be ordinary text, whereas a URL's path, +// query string and userinfo are credential material by +// position. func urlSecrets(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { @@ -188,12 +192,11 @@ func urlSecrets(raw string) []string { } if parsed.Path != "" && parsed.Path != "/" { - requestURI := parsed.RequestURI() - secrets = append(secrets, requestURI) + secrets = append(secrets, parsed.EscapedPath()) + } - if escaped := parsed.EscapedPath(); escaped != requestURI { - secrets = append(secrets, escaped) - } + if parsed.RawQuery != "" { + secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery) } if parsed.User != nil { diff --git a/internal/delivery/target_redact_test.go b/internal/delivery/target_redact_test.go index e28e302..52613de 100644 --- a/internal/delivery/target_redact_test.go +++ b/internal/delivery/target_redact_test.go @@ -202,6 +202,48 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { } } +// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an +// HTTP target URL whose credential is all in its query string. +// Written with or without the "/", the request line sends it +// as "/?token=…", and a target passing the event's query string +// on sends that after an "&". The event's part stays visible: +// the event's page shows it anyway. +func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t" + + marker := delivery.RedactionMarker + + // An echoed request line, and what the event log shows of it. + echoes := map[string]string{ + "POST /?token=" + secret + " HTTP/1.1": "POST " + marker + + " HTTP/1.1", + "POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker + + " HTTP/1.1", + "POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " + + marker + "&a=1&b=2 HTTP/1.1", + "POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" + + marker + "&a=1&b=2 HTTP/1.1", + } + + for _, dest := range []string{ + "https://example.com/?token=" + secret, + "https://example.com?token=" + secret, + } { + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeHTTP, + Config: `{"url":"` + dest + `"}`, + }) + + for echoed, want := range echoes { + assert.Equal( + t, want, r.Redact(echoed), "%s: %s", dest, echoed, + ) + } + } +} + // TestRedactor_LeavesUnrelatedTextAlone pins that the // redactor matches literally: it does not guess at what a // secret looks like, so ordinary response content survives. diff --git a/internal/delivery/target_slack.go b/internal/delivery/target_slack.go index c2f7ff7..3703b89 100644 --- a/internal/delivery/target_slack.go +++ b/internal/delivery/target_slack.go @@ -136,7 +136,7 @@ func (t *slackTarget) attempt( } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "webhooker/1.0") + req.Header.Set("User-Agent", t.eng.userAgent()) resp, doErr := executeHTTPRequest(t.client, req) durationMs := time.Since(start).Milliseconds() diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index 8eda81a..20ac2f3 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -312,13 +312,9 @@ func TestFailMissingTarget_WritesNoTargetRow( database.DeliveryStatusFailed, ) - var configs []string - - require.NoError(t, s.WebhookDB. - Table("targets"). - Pluck("config", &configs).Error) - - assert.Empty(t, configs, + // The per-webhook database has no targets table, so writing a + // target row there would have failed the status write above. + assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), "the deleted-target terminal path wrote a target row "+ "into the per-webhook event database", ) @@ -418,6 +414,38 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt( assert.Zero(t, s.Engine.ExportInflightHeld()) } +// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies +// that the failure of a retry abandoned because its target is gone is +// added to that target's own totals, not to a row with no target. +func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget( + t *testing.T, +) { + t.Parallel() + + s := newISetup(t) + + var hits atomic.Int64 + + task, targetID := tRetryChainSetup( + t, s, "gone-counted", &hits, + ) + + require.NoError(t, s.MainDB.Delete( + &database.Target{}, "id = ?", targetID, + ).Error) + + s.Engine.ExportProcessRetryTask( + context.Background(), &task, + ) + + var rows []database.TargetTotals + + require.NoError(t, s.WebhookDB.Find(&rows).Error) + assert.Equal(t, []database.TargetTotals{ + {TargetID: targetID, Failed: 1}, + }, rows) +} + // TestProcessRetryTask_TargetPresent_StillDelivers is the guard's // mutation check: a liveness check that refused every retry would pass // the test above and break every retry there is. @@ -541,6 +569,8 @@ func TestRecoverPending_TargetDeleted(t *testing.T) { s := newISetup(t) + iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery") + deliveryID := tSeedDeletedTarget( t, s, "gone-while-pending", "http://example.com/hook", database.DeliveryStatusPending, @@ -580,6 +610,8 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone( s := newISetup(t) + iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery") + deliveryID := tSeedDeletedTarget( t, s, "gone-but-owned", "http://example.com/hook", database.DeliveryStatusPending, diff --git a/internal/delivery/url_mask_test.go b/internal/delivery/url_mask_test.go index f699b36..da5327c 100644 --- a/internal/delivery/url_mask_test.go +++ b/internal/delivery/url_mask_test.go @@ -179,6 +179,27 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) { ) } +// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP +// target URL that no request can be built from. +func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) { + t.Parallel() + + e := testEngine(t, 1) + + statusCode, _, _, reqErr := e.ExportDoHTTPRequest( + context.TODO(), + &delivery.HTTPTargetConfig{ + URL: "https://hooks.example.com" + maskSecretPath + "\n", + }, + &database.Event{}, + ) + require.Error(t, reqErr) + assert.Zero(t, statusCode) + + assertNoCredential(t, reqErr.Error()) + assert.Contains(t, reqErr.Error(), "invalid control character") +} + // TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF // validator's error does not carry the submitted URL, which // the handler both logs and shows. diff --git a/internal/delivery/user_agent_test.go b/internal/delivery/user_agent_test.go new file mode 100644 index 0000000..2ff6f3c --- /dev/null +++ b/internal/delivery/user_agent_test.go @@ -0,0 +1,91 @@ +package delivery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/globals" + "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/metrics" +) + +// Both the http and the slack target send webhooker/ and the version +// in Globals, the value the web UI footer shows. A User-Agent +// configured on the target or carried in by the sender does not +// replace it. +func TestUserAgent_IsTheBuildVersion(t *testing.T) { + t.Parallel() + + const want = "webhooker/1.2.3-test" + + userAgents := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + userAgents <- r.Header.Get("User-Agent") + + w.WriteHeader(http.StatusOK) + }, + )) + defer ts.Close() + + g := &globals.Globals{Version: "1.2.3-test"} + lc := fxtest.NewLifecycle(t) + + log, err := logger.New(lc, logger.LoggerParams{Globals: g}) + require.NoError(t, err) + + e := delivery.New(lc, delivery.EngineParams{ + Globals: g, + Logger: log, + // httptest listens on loopback, which the default guard + // refuses. + SSRFGuard: delivery.NewTestGuard( + netip.MustParsePrefix("127.0.0.0/8"), + ), + Metrics: metrics.New(prometheus.NewRegistry()), + }) + + statusCode, _, _, err := e.ExportDoHTTPRequest( + context.Background(), + &delivery.HTTPTargetConfig{ + URL: ts.URL, + Headers: map[string]string{"User-Agent": "configured/1"}, + }, + &database.Event{Headers: `{"User-Agent":["curl/8"]}`}, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + require.Len(t, userAgents, 1, "the http target sent no request") + assert.Equal(t, want, <-userAgents, "http target") + + db := testWebhookDB(t) + targetID := uuid.New().String() + + slackCfg, err := json.Marshal( + delivery.SlackTargetConfig{WebhookURL: ts.URL}, + ) + require.NoError(t, err) + + event := seedEvent(t, db, `{"action":"test"}`) + dlv := seedDelivery( + t, db, event.ID, targetID, database.DeliveryStatusPending, + ) + + e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery( + dlv, event, targetID, "test-slack", string(slackCfg), + )) + require.Len(t, userAgents, 1, "the slack target sent no request") + assert.Equal(t, want, <-userAgents, "slack target") +} diff --git a/internal/gormlog/firstboot_test.go b/internal/gormlog/firstboot_test.go index 6fab1a5..337aed5 100644 --- a/internal/gormlog/firstboot_test.go +++ b/internal/gormlog/firstboot_test.go @@ -3,6 +3,7 @@ package gormlog_test import ( "context" "database/sql" + "log/slog" "os" "path/filepath" "testing" @@ -13,6 +14,7 @@ import ( "go.uber.org/fx/fxtest" _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" @@ -117,8 +119,8 @@ func readFirstBootSecrets( } // bootAtDebug starts and stops the real application graph against -// dataDir with DEBUG=true, and returns everything it wrote to standard -// output. +// dataDir with DEBUG=true and nothing else set, and returns everything +// it wrote to standard output. // // config.New reads DEBUG from the environment exactly as the binary // does, internal/logger builds the handler it builds in production, @@ -128,6 +130,7 @@ func readFirstBootSecrets( func bootAtDebug(t *testing.T, dataDir string) string { t.Helper() + configtest.ClearEnv(t) t.Setenv("DEBUG", "true") t.Setenv("DATA_DIR", dataDir) @@ -137,9 +140,14 @@ func bootAtDebug(t *testing.T, dataDir string) string { app := fxtest.New( t, + // fx's own log is discarded, not sent to t.Logf: a hook still + // running after a start or stop timeout would write there after + // the test has returned. + fx.NopLogger, fx.Provide( globals.New, logger.New, + func(l *logger.Logger) *slog.Logger { return l.Get() }, config.New, database.New, session.New, diff --git a/internal/gormlog/gormlog.go b/internal/gormlog/gormlog.go index a44dd03..07f5bd6 100644 --- a/internal/gormlog/gormlog.go +++ b/internal/gormlog/gormlog.go @@ -7,7 +7,7 @@ // SQL — parameters and all — for every statement that returns an // error, including gorm.ErrRecordNotFound. Two of this service's // lookups miss by design on unauthenticated routes: the entrypoint -// lookup on /webhook/{uuid}, whose path segment the client picks +// lookup on /h/{uuid}, whose path segment the client picks // outright, and the user lookup behind the login form, whose username // the client picks outright. Under the default logger each of those // misses printed an unbounded, attacker-chosen string, at no level the @@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface { // // One GORM path does not consult this: (*gorm.DB).Scan records the // statement through gorm's own traceRecorder, which does not implement -// this interface. No production code path calls it; its one caller is -// internal/database/database_test.go:91, whose SELECT 1 binds nothing. -// scan_guard_test.go fails if a non-test file calls it. +// this interface. No production code path calls it; only tests do, and +// what a test binds is fixture data. scan_guard_test.go fails if a +// non-test file calls it. // (*gorm.DB).Pluck, Row and Raw all run through the normal callback // processor and are filtered. func (l *Logger) ParamsFilter( diff --git a/internal/gormlog/gormlog_test.go b/internal/gormlog/gormlog_test.go index 084f98f..65b216c 100644 --- a/internal/gormlog/gormlog_test.go +++ b/internal/gormlog/gormlog_test.go @@ -135,9 +135,11 @@ func openDB( ) require.NoError(t, err) - require.NoError(t, gdb.AutoMigrate(&thing{})) + require.NoError(t, gdb.Exec( + "CREATE TABLE things (id text PRIMARY KEY, name text)", + ).Error) - // Migration chatter is not what any of these cases is about. + // Creating the table is not what any of these cases is about. buf.Reset() return gdb diff --git a/internal/gormlog/scan_guard_test.go b/internal/gormlog/scan_guard_test.go index 13a1de5..6569a4f 100644 --- a/internal/gormlog/scan_guard_test.go +++ b/internal/gormlog/scan_guard_test.go @@ -14,18 +14,16 @@ import ( "github.com/stretchr/testify/require" ) -// minNonTestFiles guards the walk below against passing because it -// found nothing to look at. The tree held 60 non-test .go files when -// this was written. -const minNonTestFiles = 40 - -// isRowProducer reports whether name is a method that returns a -// database/sql row handle. GORM's Row and Rows return *sql.Row and -// *sql.Rows, so Scan on the result of one of them is database/sql's -// Scan and never (*gorm.DB).Scan. +// isRowProducer reports whether name is GORM's Row or database/sql's +// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is +// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed: +// it also returns an error, so Scan is never called on its result +// directly. It matches the method name only and resolves no types, so +// a repo-local method with one of these names that returns *gorm.DB +// gets past it: Scan on that method's result is not reported. func isRowProducer(name string) bool { switch name { - case "Row", "Rows", "QueryRow", "QueryRowContext": + case "Row", "QueryRow", "QueryRowContext": return true default: return false @@ -50,9 +48,14 @@ func receiverIsRowHandle(x ast.Expr) bool { } // unguardedScans returns the position of every Scan call in file whose -// receiver is not a row handle. It fails closed: a receiver it cannot -// resolve syntactically — a local variable, a struct field — is -// reported rather than assumed safe. +// receiver is not a call to a row producer. It fails closed: any other +// receiver — a local variable, a struct field, a call to any other +// method — is reported rather than assumed safe. +// +// It sees only calls written x.Scan(...). A method value, f := db.Scan +// followed by f(&v), is out of scope: Scan is never the called +// expression there, and nobody writes a query that way by accident, +// which is the mistake this check exists to catch. func unguardedScans( fset *token.FileSet, file *ast.File, ) []token.Position { @@ -111,15 +114,15 @@ func skipDir(name string) bool { } } -// walkNonTestGo parses every non-test .go file under root and returns -// how many it parsed along with every unguarded Scan it found. -func walkNonTestGo(t *testing.T, root string) (int, []string) { +// walkNonTestGo parses every non-test .go file under root. It returns +// the directories, relative to root, it parsed a file in or below, +// along with every unguarded Scan it found. +func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) { t.Helper() - var ( - parsed int - hits []string - ) + walked := map[string]bool{} + + var hits []string fset := token.NewFileSet() @@ -147,7 +150,17 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) { return err } - parsed++ + dir, err := filepath.Rel(root, filepath.Dir(path)) + if err != nil { + return err + } + + // The directories above count too, so that one holding + // only a nested package, as internal/db does, is not + // taken for one the walk skipped. + for ; dir != "."; dir = filepath.Dir(dir) { + walked[dir] = true + } for _, pos := range unguardedScans(fset, file) { hits = append(hits, relPosition(root, pos)) @@ -157,7 +170,7 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) { }, )) - return parsed, hits + return walked, hits } // isNonTestGo reports whether a file name is Go source this check @@ -189,19 +202,39 @@ func relPosition(root string, pos token.Position) string { // logged with its values interpolated. The package comment states the // limit; this fails when someone adds a call site anyway. // -// The current tree has one caller, internal/database/database_test.go, -// which this check does not govern: it is test-only and its SELECT 1 -// binds nothing. +// Test files are not governed: what a test binds is fixture data. func TestGormScanIsNeverCalledOutsideTests(t *testing.T) { t.Parallel() - parsed, offenders := walkNonTestGo(t, moduleRoot(t)) + root := moduleRoot(t) + walked, offenders := walkNonTestGo(t, root) + + // The module's packages are static, templates, and every directory + // directly under cmd and internal. Each holds non-test code, itself + // or in a package below it, so one the walk parsed nothing in or + // below was skipped, and a Scan there would pass unseen. + packages := []string{"static", "templates"} + + for _, parent := range []string{"cmd", "internal"} { + entries, err := os.ReadDir(filepath.Join(root, parent)) + require.NoError(t, err) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + packages = append(packages, filepath.Join(parent, entry.Name())) + } + } + + for _, dir := range packages { + require.True( + t, walked[dir], + "the walk parsed no non-test .go file in %s", dir, + ) + } - require.GreaterOrEqual( - t, parsed, minNonTestFiles, - "parsed %d non-test .go files, so this check found "+ - "nothing to look at", parsed, - ) require.Empty( t, offenders, "Scan called on a receiver this check cannot show is a "+ @@ -222,18 +255,51 @@ type scanGuardCase struct { want int } +// scanGuardCases covers each receiver form unguardedScans names, plus +// each row producer isRowProducer lets through. Each body is valid Go +// inside plantedFile. func scanGuardCases() []scanGuardCase { return []scanGuardCase{ - {"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1}, - {"gorm receiver", `gdb.Scan(&v)`, 1}, - {"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1}, - {"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1}, - {"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0}, - {"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0}, + {"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1}, + {"struct field", `s.db.Scan(&v)`, 1}, + {"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1}, + { + "sql rows in a variable", + "rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)", + 1, + }, + {"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0}, + {"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0}, + { + "sql QueryRowContext", + `sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`, + 0, + }, {"unrelated call", `gdb.Find(&v)`, 0}, } } +// plantedFile wraps one case body in a function that declares every +// name the bodies use, so each body is the Go it stands for. The result +// is parsed, never compiled. +const plantedFile = `package p + +import ( + "context" + "database/sql" + + "gorm.io/gorm" +) + +type store struct{ db *gorm.DB } + +func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) { + var v int + + %s +} +` + // TestScanGuard_ReportsPlantedCalls proves the check fires. Without it // a detector that matched nothing would satisfy the walk above no // matter what the tree contained. @@ -245,9 +311,7 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) { t.Parallel() fset := token.NewFileSet() - src := fmt.Sprintf( - "package p\n\nfunc f() {\n\t%s\n}\n", tc.body, - ) + src := fmt.Sprintf(plantedFile, tc.body) file, err := parser.ParseFile( fset, tc.name+".go", src, 0, diff --git a/internal/handlers/archive_expiry.go b/internal/handlers/archive_expiry.go new file mode 100644 index 0000000..38a328b --- /dev/null +++ b/internal/handlers/archive_expiry.go @@ -0,0 +1,58 @@ +package handlers + +const ( + // archiveExpiryNever is the archive expiry that keeps archived + // events forever. A stored empty expiry means the same. + archiveExpiryNever = "never" + + // tmplKeyArchiveExpiryChoices is the template data key for the + // entries of a page's archive expiry select. + tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices" +) + +// archiveChoice is one entry of a database target's archive expiry +// or archive rotation select: the value stored, the label shown, and +// whether the select starts on it. +type archiveChoice struct { + Value string + Label string + Selected bool +} + +// archiveExpiryChoices lists the archive expiries offered by the new +// webhook page, the add target form and the target edit form. +func archiveExpiryChoices() []archiveChoice { + return []archiveChoice{ + {Value: archiveExpiryNever, Label: archiveExpiryNever}, + {Value: "1h", Label: "1h"}, + {Value: "12h", Label: "12h"}, + {Value: "24h", Label: "24h"}, + {Value: "720h", Label: "30d"}, + {Value: "2160h", Label: "90d"}, + {Value: "8760h", Label: "365d"}, + } +} + +// archiveExpiryOptions returns the choices with expiry selected; an +// empty expiry selects never. An expiry that is not one of the +// choices comes first as its own selected entry, so saving the form +// unchanged keeps it. +func archiveExpiryOptions(expiry string) []archiveChoice { + if expiry == "" { + expiry = archiveExpiryNever + } + + options := archiveExpiryChoices() + + for i := range options { + if options[i].Value == expiry { + options[i].Selected = true + + return options + } + } + + own := archiveChoice{Value: expiry, Label: expiry, Selected: true} + + return append([]archiveChoice{own}, options...) +} diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go new file mode 100644 index 0000000..d740f2d --- /dev/null +++ b/internal/handlers/archive_expiry_test.go @@ -0,0 +1,185 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// expiryNever is the archive expiry that keeps archived events +// forever. +const expiryNever = "never" + +// matched returns what the one group of pattern matched in page, at +// each match. +func matched(pattern, page string) []string { + matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1) + groups := make([]string, 0, len(matches)) + + for _, m := range matches { + groups = append(groups, m[1]) + } + + return groups +} + +// expiryShown returns the archive expiries the webhook page's target +// list shows. +func expiryShown( + t *testing.T, env *sourceTestEnv, webhookID string, +) []string { + t.Helper() + + w := httptest.NewRecorder() + env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest( + t, "/hook/"+webhookID, env.cookies, + map[string]string{sourceIDParam: webhookID}, + )) + require.Equal(t, http.StatusOK, w.Code) + + return matched( + `Archive expiry:\s*([^<]*)`, w.Body.String(), + ) +} + +// expirySelected returns the target edit page and the expiries its +// expiry select starts on. +func expirySelected( + t *testing.T, env *sourceTestEnv, webhookID, targetID string, +) (string, []string) { + t.Helper() + + page := targetEditPage(t, env, webhookID, targetID) + + return page, selectedIn(page, "expiry") +} + +// targetEditPage returns a target's edit page. +func targetEditPage( + t *testing.T, env *sourceTestEnv, webhookID, targetID string, +) string { + t.Helper() + + w := serveTarget( + env, http.MethodGet, + "/hook/"+webhookID+"/targets/"+targetID+"/edit", nil, + ) + require.Equal(t, http.StatusOK, w.Code) + + return w.Body.String() +} + +// selectedIn returns the values the select named name on page starts +// on. +func selectedIn(page, name string) []string { + _, rest, _ := strings.Cut(page, `\s*`+ + `\s*`+ + ``, + page, + ) + assert.Contains(t, page, ``) + + form := url.Values{} + form.Set("name", unlisted.Name) + form.Set("expiry", "36h") + + w := submitTargetEdit(env, webhook.ID, unlisted.ID, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + assert.JSONEq( + t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config, + ) +} diff --git a/internal/handlers/archive_rotation.go b/internal/handlers/archive_rotation.go new file mode 100644 index 0000000..3bdf54f --- /dev/null +++ b/internal/handlers/archive_rotation.go @@ -0,0 +1,42 @@ +package handlers + +const ( + // archiveRotationNone is the archive rotation that keeps a + // database target's archive in one file. A stored empty rotation + // means the same. + archiveRotationNone = "none" + + // tmplKeyArchiveRotationChoices is the template data key for the + // entries of a page's archive rotation select. + tmplKeyArchiveRotationChoices = "ArchiveRotationChoices" +) + +// archiveRotationChoices lists the archive rotations offered by the +// new webhook page, the add target form and the target edit form. +func archiveRotationChoices() []archiveChoice { + return []archiveChoice{ + {Value: archiveRotationNone, Label: archiveRotationNone}, + {Value: "monthly", Label: "monthly"}, + {Value: "daily", Label: "daily"}, + {Value: "hourly", Label: "hourly"}, + } +} + +// archiveRotationOptions returns the choices with rotation selected; +// an empty rotation, or one that is not a choice, selects none. A +// stored rotation is always a choice: the forms refuse any other. +func archiveRotationOptions(rotation string) []archiveChoice { + options := archiveRotationChoices() + + for i := range options { + if options[i].Value == rotation { + options[i].Selected = true + + return options + } + } + + options[0].Selected = true + + return options +} diff --git a/internal/handlers/archive_rotation_test.go b/internal/handlers/archive_rotation_test.go new file mode 100644 index 0000000..ff2c511 --- /dev/null +++ b/internal/handlers/archive_rotation_test.go @@ -0,0 +1,229 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// rotationNone is the archive rotation that keeps one file. +const rotationNone = "none" + +// rotationShown returns the archive rotations the webhook page's +// target list shows. +func rotationShown( + t *testing.T, env *sourceTestEnv, webhookID string, +) []string { + t.Helper() + + return matched( + `Archive rotation:\s*([^<]*)`, + renderedPage(t, env, webhookID), + ) +} + +// TestArchiveRotationChoices adds a database target with each archive +// rotation the forms offer, and checks that it is stored as chosen, +// shown in the target list, and that the target edit form starts on +// it. It then edits the target to hourly, keeping its expiry. +func TestArchiveRotationChoices(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} { + t.Run(rotation, func(t *testing.T) { + t.Parallel() + + webhook := seedWebhookWithRetention(t, env.db, 30) + + form := url.Values{} + form.Set("name", "archive") + form.Set("type", string(database.TargetTypeDatabase)) + form.Set("expiry", "720h") + form.Set("rotation", rotation) + + w := serveTarget( + env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form, + ) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.JSONEq(t, + `{"expiry":"720h","rotation":"`+rotation+`"}`, + targets[0].Config, + ) + + assert.Equal(t, + []string{rotation}, rotationShown(t, env, webhook.ID)) + + page := targetEditPage(t, env, webhook.ID, targets[0].ID) + assert.Equal(t, []string{rotation}, selectedIn(page, "rotation")) + + form = url.Values{} + form.Set("name", "archive") + form.Set("expiry", "720h") + form.Set("rotation", "hourly") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + assert.JSONEq(t, + `{"expiry":"720h","rotation":"hourly"}`, + storedTarget(t, env, targets[0].ID).Config, + ) + }) + } +} + +// TestArchiveRotationEditStartsOnNone checks the edit form of a +// database target with no rotation stored starts on none. +func TestArchiveRotationEditStartsOnNone(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + target := seedConfiguredTarget( + t, env.db, webhook.ID, database.TargetTypeDatabase, "", + ) + + page := targetEditPage(t, env, webhook.ID, target.ID) + assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation")) + assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID)) +} + +// TestArchiveRotationRefused proves a rotation that is not one of the +// four is refused on the add target form and the target edit form, and +// that nothing is stored. +func TestArchiveRotationRefused(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + + form := url.Values{} + form.Set("name", "archive") + form.Set("type", string(database.TargetTypeDatabase)) + form.Set("rotation", "weekly") + + w := serveTarget( + env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form, + ) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid archive rotation") + assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID)) + + target := seedConfiguredTarget( + t, env.db, webhook.ID, database.TargetTypeDatabase, + `{"rotation":"daily"}`, + ) + + form.Del("type") + + w = submitTargetEdit(env, webhook.ID, target.ID, form) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid archive rotation") + assert.JSONEq(t, + `{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config, + ) +} + +// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook +// page's archive rotation is stored on the archive target it creates. +func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + form := url.Values{} + form.Set("name", "rotated") + form.Set("archive", "on") + form.Set("archive_expiry", "720h") + form.Set("archive_rotation", "daily") + + w := submitCreateForm(env, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + var webhook database.Webhook + + require.NoError(t, env.db.DB(). + Where("name = ?", "rotated").First(&webhook).Error) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.JSONEq(t, + `{"expiry":"720h","rotation":"daily"}`, targets[0].Config, + ) +} + +// TestArchiveFileView_Rotated describes a daily target's archive files +// at two times. On a day that has a file, the view names that file; +// on the next, before any event, it names the file the next event +// will go to, not created yet. Both times the size is of every file +// together and the last write the latest of them. +func TestArchiveFileView_Rotated(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + target := seedConfiguredTarget( + t, env.db, webhook.ID, database.TargetTypeDatabase, + `{"rotation":"daily"}`, + ) + + path := delivery.ArchivePath(env.dbMgr, &webhook, target) + stem := strings.TrimSuffix(path, ".db") + written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) + + for i, day := range []string{"2026-10-01", "2026-10-02"} { + file := stem + "-" + day + ".db" + require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600)) + + at := written.Add(time.Duration(i-1) * 24 * time.Hour) + require.NoError(t, os.Chtimes(file, at, at)) + } + + view := env.handlers.ArchiveFileViewForTest( + &webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC), + ) + assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name) + assert.Empty(t, view.Note) + assert.Equal(t, 2, view.Files) + assert.Equal(t, "2.0 kB", view.Size) + assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC) + + view = env.handlers.ArchiveFileViewForTest( + &webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), + ) + assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name) + assert.Equal(t, "not created yet", view.Note) + assert.Equal(t, 2, view.Files) + assert.Equal(t, "2.0 kB", view.Size) + + page := targetList(t, renderedPage(t, env, webhook.ID)) + assert.Contains(t, page, "Archive size: 2.0 kB in 2 files") +} + +// renderedPage returns the webhook page. +func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string { + t.Helper() + + w := httptest.NewRecorder() + env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest( + t, "/hook/"+webhookID, env.cookies, + map[string]string{sourceIDParam: webhookID}, + )) + require.Equal(t, http.StatusOK, w.Code) + + return w.Body.String() +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 39fa5dc..7a1b6ac 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -2,19 +2,56 @@ package handlers import ( "net/http" + "net/url" "strconv" + "strings" + "unicode" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/logfield" + "sneak.berlin/go/webhooker/internal/middleware" ) +// loginDestination returns where a successful login sends the +// browser: next when it is a path on this site, otherwise "/", which +// leads to the webhook list. +// +// A browser reads "//host" as another site, reads "\" as "/", and +// drops tabs and newlines before reading at all. So the value must +// start with exactly one "/" and hold no "\" or control character +// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It +// is checked after percent-decoding, so an encoded form of any of +// these is refused too. +func loginDestination(next string) string { + if len(next) > middleware.MaxNextBytes { + return "/" + } + + decoded, err := url.PathUnescape(next) + if err != nil || + !strings.HasPrefix(decoded, "/") || + strings.HasPrefix(decoded, "//") || + strings.Contains(decoded, `\`) || + strings.ContainsFunc(decoded, unicode.IsControl) { + return "/" + } + + return next +} + // HandleLoginPage returns a handler for the login page (GET) func (h *Handlers) HandleLoginPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + next := loginDestination( + r.URL.Query().Get(middleware.NextParam), + ) + // Check if already logged in sess, err := h.session.Get(r) if err == nil && h.session.IsAuthenticated(sess) { - http.Redirect(w, r, "/", http.StatusSeeOther) + http.Redirect( //nolint:gosec // checked by loginDestination + w, r, next, http.StatusSeeOther, + ) return } @@ -22,6 +59,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc { // Render login page data := map[string]any{ tmplKeyError: "", + tmplKeyNext: next, } h.renderTemplate(w, r, "login.html", data) @@ -36,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { err := r.ParseForm() if err != nil { h.log.Error("failed to parse form", "error", err) - http.Error(w, "Bad request", http.StatusBadRequest) + h.renderError(w, r, http.StatusBadRequest) return } @@ -77,8 +115,13 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { "user_id", user.ID, ) - // Redirect to home page - http.Redirect(w, r, "/", http.StatusSeeOther) + // The form value is the client's to set, so it is checked + // again here rather than trusted from the rendered page. + http.Redirect( //nolint:gosec // checked by loginDestination + w, r, + loginDestination(r.PostFormValue(middleware.NextParam)), + http.StatusSeeOther, + ) } } @@ -91,10 +134,12 @@ func (h *Handlers) renderLoginError( ) { data := map[string]any{ tmplKeyError: msg, + tmplKeyNext: loginDestination( + r.PostFormValue(middleware.NextParam), + ), } - w.WriteHeader(status) - h.renderTemplate(w, r, "login.html", data) + h.renderTemplateStatus(w, r, "login.html", data, status) } // authenticateUser looks up and verifies a user's credentials. @@ -103,9 +148,10 @@ func (h *Handlers) renderLoginError( // The credential check runs BEFORE any rate-limit budget is // consulted, and only a failed check spends budget. That is what // keeps the single administrative path reachable: behind the reverse -// proxy this deployment requires, with TRUSTED_PROXIES unset, every -// client shares one bucket, so a limiter spent on arrival lets any -// stranger deny the operator's own correct password indefinitely. +// proxy this deployment requires, when TRUSTED_PROXIES does not cover +// it, every client shares one bucket, so a limiter spent on arrival +// lets any stranger deny the operator's own correct password +// indefinitely. // // Verifying first means every login POST costs an Argon2id hash, so // the work is taken under a bounded number of verification slots. @@ -165,11 +211,7 @@ func (h *Handlers) authenticateUser( valid, err := database.VerifyPassword(password, user.Password) if err != nil { - h.log.Error("failed to verify password", "error", err) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.serverError(w, r, "failed to verify password", err) return user, err } @@ -226,7 +268,7 @@ func (h *Handlers) rejectLogin( ))) h.renderLoginError( w, r, - "Too many failed login attempts. Please try again later.", + "Too many failed sign-in attempts. Please try again later.", http.StatusTooManyRequests, ) } @@ -241,24 +283,14 @@ func (h *Handlers) createAuthenticatedSession( ) error { oldSess, err := h.session.Get(r) if err != nil { - h.log.Error("failed to get session", "error", err) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.serverError(w, r, "failed to get session", err) return err } sess, err := h.session.Regenerate(r, w, oldSess) if err != nil { - h.log.Error( - "failed to regenerate session", "error", err, - ) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.serverError(w, r, "failed to regenerate session", err) return err } @@ -267,11 +299,7 @@ func (h *Handlers) createAuthenticatedSession( err = h.session.Save(r, w, sess) if err != nil { - h.log.Error("failed to save session", "error", err) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.serverError(w, r, "failed to save session", err) return err } @@ -304,7 +332,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc { ) } - // Redirect to login page - http.Redirect(w, r, "/pages/login", http.StatusSeeOther) + http.Redirect( + w, r, withNotice("/pages/login", signedOut), + http.StatusSeeOther, + ) } } diff --git a/internal/handlers/auth_test.go b/internal/handlers/auth_test.go index 98c64c8..d5c2336 100644 --- a/internal/handlers/auth_test.go +++ b/internal/handlers/auth_test.go @@ -3,6 +3,7 @@ package handlers_test import ( "context" "fmt" + "html/template" "net/http" "net/http/httptest" "net/url" @@ -25,7 +26,7 @@ const ( // sharedProxyPeer is the whole point of this file. Production is // required to run behind a TLS-terminating reverse proxy, and - // TRUSTED_PROXIES defaults to empty, so every client — attacker + // when TRUSTED_PROXIES does not cover it every client — attacker // and operator alike — reaches the process from the proxy's // address and shares one rate-limit bucket. Both parties in // these tests therefore use the same RemoteAddr. @@ -115,11 +116,11 @@ func floodFailures( // done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150. // // The attacker and the operator share one rate-limit bucket, because -// behind the mandated reverse proxy with TRUSTED_PROXIES unset every -// client keys on the proxy's address. The attacker floods the -// operator's own username — a single-admin product has a predictable -// one — far past the failure limit. The operator must still be able -// to log in with the correct password. +// behind the mandated reverse proxy, when TRUSTED_PROXIES does not +// cover it, every client keys on the proxy's address. The attacker +// floods the operator's own username — a single-admin product has a +// predictable one — far past the failure limit. The operator must +// still be able to log in with the correct password. // // This fails if credentials stop being verified ahead of the limiter. func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) { @@ -404,6 +405,60 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) { ) } +// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login +// form shown again with an error still answers 400 with the whole page. +func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + app := newTestApp(t, &h) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + w := submitLogin(h, sharedProxyPeer, "", "") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains( + t, w.Body.String(), "Username and password are required", + ) + assert.Contains( + t, w.Body.String(), "", + "the page must render to completion", + ) +} + +// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form +// error page whose template fails answers 500 with the error page and +// none of the form page, rather than the 400 it meant to send. +func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + app := newTestApp(t, &h) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + // The page prints its error message and then fails. + h.AddTemplateForTest("login.html", template.Must( + template.New("login").Funcs(template.FuncMap{ + "fail": func() (string, error) { return "", errMidRender }, + }).Parse(`{{.Error}}{{fail}}`), + )) + + w := submitLogin(h, sharedProxyPeer, "", "") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.NotContains( + t, w.Body.String(), "Username and password are required", + "the response must carry no part of the aborted page", + ) + assert.Contains(t, w.Body.String(), "500 Internal Server Error") +} + // TestLogin_SuccessCreatesSession is the control for the tests above: // the success path they assert on really does authenticate. func TestLogin_SuccessCreatesSession(t *testing.T) { @@ -453,3 +508,229 @@ func TestLogin_SuccessCreatesSession(t *testing.T) { "the issued cookie must carry an authenticated session", ) } + +// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of +// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login +// returns to is client-chosen, so anything that is not a path on this +// site, plain or percent-encoded, must land on "/", the webhook list. +func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + db *database.Database + ) + + app := newTestApp(t, &h, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + seedOperator(t, db) + + cases := []struct{ next, want string }{ + {"/hook/abc/events?page=2", "/hook/abc/events?page=2"}, + {"", "/"}, + {"https://evil.example/", "/"}, + {"https%3A%2F%2Fevil.example%2F", "/"}, + {"//evil.example/", "/"}, + {"%2F%2Fevil.example/", "/"}, + {"/%2Fevil.example/", "/"}, + {`/\evil.example/`, "/"}, + {"%2F%5Cevil.example/", "/"}, + {"/%5Cevil.example/", "/"}, + {`/a/../\evil.example/`, "/"}, + {"/\t/evil.example/", "/"}, + {"/%09/evil.example/", "/"}, + {"/\n/evil.example/", "/"}, + {"/%0A/evil.example/", "/"}, + {"/\r/evil.example/", "/"}, + {"/%0D/evil.example/", "/"}, + {"/%00/evil.example/", "/"}, + {"/%7F/evil.example/", "/"}, + {"%252F%252Fevil.example/", "/"}, + {"https%253A%252F%252Fevil.example%252F", "/"}, + {"/" + strings.Repeat("a", 4096), "/"}, + } + + for _, c := range cases { + form := url.Values{} + form.Set("username", operatorUser) + form.Set("password", operatorPassword) + form.Set("next", c.next) + + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/pages/login", + strings.NewReader(form.Encode()), + ) + req.Header.Set( + "Content-Type", "application/x-www-form-urlencoded", + ) + req.RemoteAddr = sharedProxyPeer + + w := httptest.NewRecorder() + h.HandleLoginSubmit().ServeHTTP(w, req) + + assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next) + assert.Equal( + t, c.want, w.Header().Get("Location"), "next %q", c.next, + ) + } +} + +// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong +// password the login page is shown again with the same next, so the +// next attempt still returns to the page that was asked for. +func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + db *database.Database + ) + + app := newTestApp(t, &h, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + seedOperator(t, db) + + form := url.Values{} + form.Set("username", operatorUser) + form.Set("password", "wrong") + form.Set("next", "/hook/abc") + + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/pages/login", + strings.NewReader(form.Encode()), + ) + req.Header.Set( + "Content-Type", "application/x-www-form-urlencoded", + ) + req.RemoteAddr = sharedProxyPeer + + w := httptest.NewRecorder() + h.HandleLoginSubmit().ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains( + t, w.Body.String(), `name="next" value="/hook/abc"`, + ) +} + +// loginPageGet renders the login page as a GET with the given next +// value and cookies. +func loginPageGet( + h *handlers.Handlers, next string, cookies []*http.Cookie, +) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, + "/pages/login?"+url.Values{"next": {next}}.Encode(), nil, + ) + + for _, c := range cookies { + req.AddCookie(c) + } + + w := httptest.NewRecorder() + h.HandleLoginPage().ServeHTTP(w, req) + + return w +} + +// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page +// itself: its form carries the requested page only when it is a path +// on this site, and a browser already logged in goes straight there, +// or to "/" when it is not. +func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + ) + + app := newTestApp(t, &h, &sess) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + assert.Contains( + t, loginPageGet(h, "/hook/abc", nil).Body.String(), + `name="next" value="/hook/abc"`, + ) + assert.Contains( + t, loginPageGet(h, "//evil.example/", nil).Body.String(), + `name="next" value="/"`, + ) + + cookies := authenticatedCookies(t, sess, "test-user-id", "testuser") + + cases := []struct{ next, want string }{ + {"/hook/abc", "/hook/abc"}, + {"//evil.example/", "/"}, + {`/\evil.example/`, "/"}, + } + + for _, c := range cases { + w := loginPageGet(h, c.next, cookies) + + assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next) + assert.Equal( + t, c.want, w.Header().Get("Location"), "next %q", c.next, + ) + } +} + +// TestLoginPage_HasNoLinkToItself: the navigation bar on the login +// page offers no link to the login page. +func TestLoginPage_HasNoLinkToItself(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + app := newTestApp(t, &h) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + w := loginPageGet(h, "", nil) + + require.Equal(t, http.StatusOK, w.Code) + assert.NotContains(t, w.Body.String(), `href="/pages/login"`) +} + +// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly +// database.MaxUsernameBytes still fits in the session cookie. Past +// what the cookie can carry, a correct login answers 500. +func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + db *database.Database + ) + + app := newTestApp(t, &h, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + username := strings.Repeat("a", database.MaxUsernameBytes) + + hash, err := database.HashPassword(operatorPassword) + require.NoError(t, err) + require.NoError(t, db.DB().Create(&database.User{ + Username: username, + Password: hash, + }).Error) + + w := submitLogin(h, sharedProxyPeer, username, operatorPassword) + + assert.Equal(t, http.StatusSeeOther, w.Code) +} diff --git a/internal/handlers/delivery_attempts_test.go b/internal/handlers/delivery_attempts_test.go new file mode 100644 index 0000000..55d82f1 --- /dev/null +++ b/internal/handlers/delivery_attempts_test.go @@ -0,0 +1,89 @@ +package handlers_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the +// event's page and in the event log, that an http or slack attempt +// shows its status as before, while a database or log attempt, which +// sends no HTTP request, says what it did and shows no status. +func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) { + t.Parallel() + + cases := []struct { + targetType database.TargetType + success bool + statusCode int + errText string + outcome string + status string // "" when the attempt must show no status + }{ + { + database.TargetTypeHTTP, false, 0, "", + "failure", "Status: — (no response)", + }, + { + database.TargetTypeSlack, true, http.StatusOK, "", + "success", "Status: 200", + }, + {database.TargetTypeDatabase, true, 0, "", "archived", ""}, + { + database.TargetTypeDatabase, false, 0, + "opening archive database: disk full", "failure", "", + }, + {database.TargetTypeLog, true, 0, "", "written to the log", ""}, + } + + for _, tc := range cases { + t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, tc.targetType) + event := f.event(t, contentTypeJSON, "{}", time.Now()) + dlv := f.delivery( + t, event, target.ID, database.DeliveryStatusDelivered, + ) + + require.NoError(t, f.webhookDB.Omit(clause.Associations).Create( + &database.DeliveryResult{ + DeliveryID: dlv.ID, + AttemptNum: 1, + Success: tc.success, + StatusCode: tc.statusCode, + Error: tc.errText, + }, + ).Error) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + pages := []string{ + w.Body.String(), + renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID), + } + + for _, page := range pages { + assert.Contains(t, page, ">"+tc.outcome+"") + + if tc.errText != "" { + assert.Contains(t, page, "Error: "+tc.errText) + } + + if tc.status == "" { + assert.NotContains(t, page, "Status:") + } else { + assert.Contains(t, page, tc.status) + } + } + }) + } +} diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index ee9425b..96a8cfc 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strconv" "github.com/go-chi/chi" "gorm.io/gorm" @@ -11,72 +10,39 @@ import ( "sneak.berlin/go/webhooker/internal/delivery" ) -// replayOutcomeParam is the query parameter the replay POST redirects -// with and the event log page reads its banner from. -const replayOutcomeParam = "replay" - -// replayOutcomeCode is the outcome of a replay POST. The redirect -// carries one of these fixed codes rather than a message, so nothing a -// client submits can reach the rendered page through it. -type replayOutcomeCode string - +// The outcomes of a replay POST, as the notice codes its redirect +// carries. noticeFor holds the line each one shows. const ( // replayQueued reports that a new delivery was created and handed // to the delivery engine. - replayQueued replayOutcomeCode = "queued" + replayQueued noticeCode = "replay-queued" // replayTargetDeleted reports a target that once existed and has // since been deleted. Deletes are soft and deliveries carry no // foreign key to the target row, so the history survives its - // target and this is the ordinary case for an old event. - replayTargetDeleted replayOutcomeCode = "target-deleted" + // target and this is the ordinary case for an old event. The + // event log shows no Replay button for such a delivery, so only + // a page loaded before the delete reaches this. + replayTargetDeleted noticeCode = "replay-target-deleted" // replayTargetMissing reports a target id that names no row at // all, deleted or otherwise. - replayTargetMissing replayOutcomeCode = "target-missing" + replayTargetMissing noticeCode = "replay-target-missing" // replayTargetInactive reports a target the operator has // deactivated. A deactivated target receives no new deliveries, so // a replay to it would be a delivery they switched off. - replayTargetInactive replayOutcomeCode = "target-inactive" + replayTargetInactive noticeCode = "replay-target-inactive" // replayNotTerminal reports a delivery the engine has not finished // with. - replayNotTerminal replayOutcomeCode = "not-terminal" + replayNotTerminal noticeCode = "replay-not-terminal" // replayInFlight reports that an earlier replay of this event to // this target is still running. - replayInFlight replayOutcomeCode = "in-flight" + replayInFlight noticeCode = "replay-in-flight" ) -// replayOutcome returns the banner the event log page shows for an -// outcome code, and whether the replay was queued. An unrecognised -// code yields no banner. -func replayOutcome(code string) (string, bool) { - switch replayOutcomeCode(code) { - case replayQueued: - return "Replay queued: a new delivery was created against " + - "the target's current configuration.", true - case replayTargetDeleted: - return "Not replayed: the target this delivery was for has " + - "been deleted. Recreate the target, then replay.", false - case replayTargetMissing: - return "Not replayed: the target this delivery was for no " + - "longer exists.", false - case replayTargetInactive: - return "Not replayed: the target this delivery was for is " + - "deactivated. Activate it, then replay.", false - case replayNotTerminal: - return "Not replayed: this delivery has not finished yet.", - false - case replayInFlight: - return "Not replayed: a delivery of this event to this " + - "target is already in flight.", false - default: - return "", false - } -} - // HandleDeliveryReplay re-sends a finished delivery's event to its // target. // @@ -105,9 +71,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc { // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) + h.renderError(w, r, http.StatusBadRequest) return } @@ -124,14 +88,14 @@ func (h *Handlers) replayDelivery( webhook database.Webhook, ) { if !h.dbMgr.DBExists(webhook.ID) { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { - h.serverError(w, "failed to get webhook database", err) + h.serverError(w, r, "failed to get webhook database", err) return } @@ -142,14 +106,14 @@ func (h *Handlers) replayDelivery( } if !original.Status.Terminal() { - h.finishReplay(w, r, webhook, replayNotTerminal) + redirectToEventLog(w, r, webhook, replayNotTerminal) return } target, code := h.replayTarget(webhook.ID, original.TargetID) if target == nil { - h.finishReplay(w, r, webhook, code) + redirectToEventLog(w, r, webhook, code) return } @@ -173,7 +137,7 @@ func (h *Handlers) loadReplaySource( &original, "id = ?", chi.URLParam(r, "deliveryID"), ).Error if err != nil { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return nil, false } @@ -195,14 +159,14 @@ func (h *Handlers) queueReplay( ) if err != nil { h.serverError( - w, "failed to count in-flight deliveries", err, + w, r, "failed to count in-flight deliveries", err, ) return } if inFlight > 0 { - h.finishReplay(w, r, webhook, replayInFlight) + redirectToEventLog(w, r, webhook, replayInFlight) return } @@ -212,7 +176,7 @@ func (h *Handlers) queueReplay( err = webhookDB. First(&event, "id = ?", original.EventID).Error if err != nil { - h.serverError(w, "failed to load event for replay", err) + h.serverError(w, r, "failed to load event for replay", err) return } @@ -222,7 +186,7 @@ func (h *Handlers) queueReplay( ) if err != nil { h.serverError( - w, "failed to create replay delivery", err, + w, r, "failed to create replay delivery", err, ) return @@ -240,7 +204,7 @@ func (h *Handlers) queueReplay( "delivery_id", task.DeliveryID, ) - h.finishReplay(w, r, webhook, replayQueued) + redirectToEventLog(w, r, webhook, replayQueued) } // replayTarget loads the delivery's target as it stands now. @@ -253,7 +217,7 @@ func (h *Handlers) queueReplay( // with the returned code saying why. func (h *Handlers) replayTarget( webhookID, targetID string, -) (*database.Target, replayOutcomeCode) { +) (*database.Target, noticeCode) { var target database.Target err := h.db.DB().Unscoped().Where( @@ -299,8 +263,9 @@ func countInFlightDeliveries( return count, err } -// createReplayDelivery writes the new pending delivery row and returns -// the task that carries it to the delivery engine. +// createReplayDelivery writes the new pending delivery row, adds it to +// its target's totals in the same transaction, and returns the task +// that carries it to the delivery engine. // // The row is written with associations omitted, and neither Event nor // Target is populated on it: GORM's SaveBeforeAssociations would @@ -317,9 +282,19 @@ func createReplayDelivery( EventID: event.ID, TargetID: target.ID, Status: database.DeliveryStatusPending, + Replay: true, } - err := webhookDB.Omit(clause.Associations).Create(dlv).Error + err := webhookDB.Transaction(func(tx *gorm.DB) error { + err := tx.Omit(clause.Associations).Create(dlv).Error + if err != nil { + return err + } + + return database.AddTargetTotals(tx, database.TargetTotals{ + TargetID: dlv.TargetID, Deliveries: 1, + }) + }) if err != nil { return delivery.Task{}, err } @@ -335,6 +310,7 @@ func createReplayDelivery( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: replayBody(event.Body), @@ -353,26 +329,26 @@ func replayBody(body string) *string { return &body } -// finishReplay redirects back to the event log the replay was -// triggered from, carrying the outcome code the page turns into a -// banner and the page number the form submitted. -func (h *Handlers) finishReplay( +// redirectToEventLog redirects a replay or resubmit back to the event +// log it was triggered from, carrying the outcome as its notice. A +// Replay form carries the list it was pressed in as show, so a replay +// returns to the Failed or Pending list; a Resubmit form carries none, +// so a resubmit returns to the full log, where its new event is the +// newest. +func redirectToEventLog( w http.ResponseWriter, r *http.Request, webhook database.Webhook, - code replayOutcomeCode, + code noticeCode, ) { - dest := "/source/" + webhook.ID + "/logs?" + - replayOutcomeParam + "=" + string(code) + location := withNotice("/hook/"+webhook.ID+"/events", code) - // The page is read from the form rather than the query string: - // this is a POST, and its query string is what logs and Referer - // headers record. - if page := pageOrFirst( - r.PostFormValue("page"), - ); page > 1 { - dest += "&page=" + strconv.Itoa(page) + show := r.PostFormValue(showParam) + if eventLogStatuses(show) != nil { + location += "&" + showParam + "=" + show } - http.Redirect(w, r, dest, http.StatusSeeOther) + http.Redirect( //nolint:gosec // show is checked by eventLogStatuses + w, r, location, http.StatusSeeOther, + ) } diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 4d8a3be..b9ed423 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -1,8 +1,10 @@ package handlers_test import ( + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -24,6 +26,9 @@ const paramDeliveryID = "deliveryID" // dispatches to it: the notifier is recorded, not run. const replayTargetURL = "http://93.184.216.34/hook" +// replayEventQuery is the query string a seeded event arrived with. +const replayEventQuery = "a=1&b=2" + // seedFailedDelivery records an event, a terminally failed delivery of // it to the given target, and the attempt that failed. func seedFailedDelivery( @@ -40,6 +45,7 @@ func seedFailedDelivery( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: replayEventQuery, Headers: `{"X-Test":["yes"]}`, Body: `{"replay":"me"}`, ContentType: contentTypeJSON, @@ -138,7 +144,7 @@ func postReplay( t.Helper() req := postRequest( - "/source/"+webhookID+"/deliveries/"+ + "/hook/"+webhookID+"/deliveries/"+ deliveryID+"/replay", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, @@ -212,7 +218,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=queued", + "/hook/"+wh.ID+"/events?notice=replay-queued", w.Header().Get("Location"), ) @@ -294,6 +300,10 @@ func assertReplayTask( "replay must use the target's current configuration", ) assert.Equal(t, event.Method, task.Method) + assert.Equal( + t, replayEventQuery, task.RawQuery, + "replay re-sends the stored query string", + ) assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) @@ -304,21 +314,15 @@ func assertReplayTask( ) } -// assertNoLeakedTarget proves the per-webhook database holds no target -// rows. AutoMigrate creates the table there because Delivery declares -// the relation, so it is a ROW that signals a leak: an association -// write would have upserted the whole target, plaintext config and -// all, into the event database. See +// assertNoLeakedTarget proves the per-webhook database holds no +// target: an association write would have upserted the whole target, +// plaintext config and all, into the event database. See // https://git.eeqj.de/sneak/webhooker/issues/206. func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) { t.Helper() - var leaked int64 - - require.NoError(t, webhookDB.Unscoped(). - Model(&database.Target{}).Count(&leaked).Error) - assert.Zero( - t, leaked, + assert.False( + t, webhookDB.Migrator().HasTable(&database.Target{}), "replay must not write the target into the event database", ) } @@ -362,7 +366,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=target-deleted", + "/hook/"+wh.ID+"/events?notice=replay-target-deleted", w.Header().Get("Location"), ) @@ -390,7 +394,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, missing.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=target-missing", + "/hook/"+wh.ID+"/events?notice=replay-target-missing", missing.Header().Get("Location"), ) } @@ -431,7 +435,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, first.Code) require.Equal( t, - "/source/"+wh.ID+"/logs?replay=queued", + "/hook/"+wh.ID+"/events?notice=replay-queued", first.Header().Get("Location"), ) @@ -439,7 +443,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, second.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=in-flight", + "/hook/"+wh.ID+"/events?notice=replay-in-flight", second.Header().Get("Location"), ) @@ -465,11 +469,71 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, pending.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=not-terminal", + "/hook/"+wh.ID+"/events?notice=replay-not-terminal", pending.Header().Get("Location"), ) } +// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a +// Replay pressed in the Failed list carries that list in its form and +// returns to it, and that a show value the event log does not know +// returns to the full log. +func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn( + t *testing.T, +) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, database.TargetTypeHTTP, + `{"url":"`+replayTargetURL+`"}`, + ) + + _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + + assert.Contains(t, renderSourceLogsPageWithQuery( + t, h, sess, wh.ID, "?show=failed", + ), `name="show" value="failed"`) + + // The second replay is refused, as the first is still queued. + for _, tc := range []struct{ show, location string }{ + {"failed", "/hook/" + wh.ID + + "/events?notice=replay-queued&show=failed"}, + {"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"}, + } { + req := postRequest( + "/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay", + authenticatedCookies( + t, sess, deleteTestUserID, deleteTestUsername, + ), + map[string]string{ + paramSourceID: wh.ID, + paramDeliveryID: original.ID, + }, + ) + req.Body = io.NopCloser(strings.NewReader("show=" + tc.show)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + h.HandleDeliveryReplay().ServeHTTP(w, req) + + require.Equal(t, http.StatusSeeOther, w.Code, tc.show) + assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show) + } +} + // TestHandleSourceLogs_RendersReplayControlAndBanner proves the action // reaches the page it belongs on: a finished delivery renders a POST // form carrying a CSRF token, and the outcome code a refusal redirects @@ -501,7 +565,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) { assert.Contains( t, body, - `action="/source/`+wh.ID+`/deliveries/`+ + `action="/hook/`+wh.ID+`/deliveries/`+ original.ID+`/replay"`, ) assert.Contains(t, body, `method="POST"`) @@ -509,18 +573,77 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) { assert.Contains(t, body, ">Replay<") refused := renderSourceLogsPageWithQuery( - t, h, sess, wh.ID, "?replay=target-deleted", + t, h, sess, wh.ID, "?notice=replay-target-deleted", ) assert.Contains(t, refused, "alert-error") - assert.Contains(t, refused, "has been deleted") + assert.Contains( + t, refused, + "has been deleted. Use Resubmit to send the event "+ + "to the webhook", + ) // An outcome code nobody issued renders no banner at all. unknown := renderSourceLogsPageWithQuery( - t, h, sess, wh.ID, "?replay=made-up", + t, h, sess, wh.ID, "?notice=made-up", ) assert.NotContains(t, unknown, "alert-error") assert.NotContains(t, unknown, "alert-success") assert.NotContains(t, unknown, "made-up") } + +// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created +// by Replay is labelled as a replay in the event's summary line in the +// event log, and in the list of the event's deliveries there and on +// the event's page, while the delivery it repeats is not. +func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, database.TargetTypeHTTP, + `{"url":"`+replayTargetURL+`"}`, + ) + + event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + + w := postReplay(t, h, sess, wh.ID, original.ID) + require.Equal(t, http.StatusSeeOther, w.Code) + + eventLog := renderSourceLogsPage(t, h, sess, wh.ID) + + assert.Contains(t, eventLog, tgt.Name+": failed") + assert.Contains(t, eventLog, tgt.Name+" (replay): pending") + + w = serveEventPage(t, h, sess, wh.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + // In each delivery list a row names the target, then the label if + // it is a replay, then its status: the replay is still pending, the + // original failed. + replayRow := tgt.Name + ` ` + + `replay ` + + `pending` + originalRow := tgt.Name + ` ` + + `failed` + + for _, page := range []string{eventLog, w.Body.String()} { + page = strings.Join(strings.Fields(page), " ") + + assert.Contains(t, page, replayRow) + assert.Contains(t, page, originalRow) + } +} diff --git a/internal/handlers/delivery_result_view.go b/internal/handlers/delivery_result_view.go index e3bb27b..76140c5 100644 --- a/internal/handlers/delivery_result_view.go +++ b/internal/handlers/delivery_result_view.go @@ -1,6 +1,9 @@ package handlers import ( + "time" + + "github.com/dustin/go-humanize" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -9,13 +12,12 @@ import ( // // The bound is enforced here and in SQL, because this page's // memory profile must not depend on a constant in another -// package staying where it is, and because rows predating the -// delivery engine's own cap or restored from an archive are -// not covered by it at all. +// package staying where it is, or on every stored row having +// been written by the delivery engine under its own cap. // // It happens to equal that engine cap, so a row written by the -// current engine reaches this one exactly and is never cut -// twice. Nothing here may assume the two differ: see view. +// engine reaches this one exactly and is never cut twice. +// Nothing here may assume the two differ: see view. const maxRenderedResponseBytes = 4096 // deliveryResultColumns is the delivery attempt projection. @@ -24,8 +26,8 @@ const maxRenderedResponseBytes = 4096 // bytes rather than characters, and they make SQLite do the // cut, so an oversized stored response never becomes a Go // string at all. -const deliveryResultColumns = "delivery_id, attempt_num, success, " + - "status_code, error, duration, " + +const deliveryResultColumns = "delivery_id, attempt_num, created_at, " + + "success, status_code, error, duration, " + "substr(cast(response_body as blob), 1, ?) AS response_body, " + "length(cast(response_body as blob)) AS response_bytes" @@ -37,14 +39,18 @@ const deliveryResultColumns = "delivery_id, attempt_num, success, " + // // Both Error and ResponseBody have been through the target's // Redactor. The engine already masks the URL out of the -// errors it stores, so for errors this is a second line -// covering rows written before it did; for response bodies it -// is the only line, and its reach is what +// errors it stores, so for errors this is a second line; for +// response bodies it is the only line, and its reach is what // delivery.Redactor documents. type DeliveryResultView struct { AttemptNum int Success bool + // Ran is how long ago the attempt was recorded, and RanUTC the + // full timestamp the page shows on hover. + Ran string + RanUTC string + // StatusCode is 0 when the attempt never got a response, // which is why the page asks HasStatusCode rather than // printing the number. @@ -100,6 +106,7 @@ func (v DeliveryResultView) HasStatusCode() bool { type deliveryResultRow struct { DeliveryID string AttemptNum int + CreatedAt time.Time Success bool StatusCode int Error string @@ -157,6 +164,8 @@ func (r *deliveryResultRow) view( return DeliveryResultView{ AttemptNum: r.AttemptNum, Success: r.Success, + Ran: humanize.Time(r.CreatedAt), + RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC", StatusCode: r.StatusCode, Error: redactor.Redact(r.Error), DurationMS: r.Duration, diff --git a/internal/handlers/delivery_result_view_test.go b/internal/handlers/delivery_result_view_test.go index 8b4a388..66fc386 100644 --- a/internal/handlers/delivery_result_view_test.go +++ b/internal/handlers/delivery_result_view_test.go @@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) { }).Error) } - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) @@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) { stored := strings.Repeat("A", responseCap*4) + tail seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries[0].Results, 1) diff --git a/internal/handlers/entrypoint.go b/internal/handlers/entrypoint.go new file mode 100644 index 0000000..6faac72 --- /dev/null +++ b/internal/handlers/entrypoint.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleEntrypointCreate handles adding a new entrypoint. +func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + description := r.PostFormValue("description") + + entrypoint := &database.Entrypoint{ + WebhookID: webhook.ID, + Path: uuid.New().String(), + Description: description, + Active: true, + } + + err = h.db.DB().Create(entrypoint).Error + if err != nil { + h.serverError(w, r, "failed to create entrypoint", err) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, entrypointAdded), + http.StatusSeeOther, + ) + } +} + +// HandleEntrypointEdit handles changing an entrypoint's description. +// It writes only the description column, so the entrypoint keeps its +// URL, and an activate or deactivate saved since the page was shown +// is not undone. +func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + entrypointID := chi.URLParam(r, "entrypointID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + result := h.db.DB().Model(&database.Entrypoint{}).Where( + "id = ? AND webhook_id = ?", entrypointID, webhook.ID, + ).Update("description", r.PostFormValue("description")) + if result.Error != nil { + h.serverError( + w, r, "failed to edit entrypoint", result.Error, + ) + + return + } + + // The id came from the URL and may name another webhook's + // entrypoint, which this webhook does not have. + if result.RowsAffected == 0 { + h.renderError(w, r, http.StatusNotFound) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, entrypointSaved), + http.StatusSeeOther, + ) + } +} + +// HandleEntrypointDelete handles deleting an entrypoint. +func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { + return h.deleteChildResource( + "entrypointID", &database.Entrypoint{}, + "failed to delete entrypoint", + nil, + entrypointDeleted, + ) +} + +// HandleEntrypointToggle handles toggling an entrypoint's +// active state. +func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { + return h.toggleChildResource( + "entrypointID", + func(webhookID, childID string) (bool, error) { + var ep database.Entrypoint + + err := h.db.DB().Where( + "id = ? AND webhook_id = ?", + childID, webhookID, + ).First(&ep).Error + if err != nil { + return false, err + } + + // Only the active column: saving the whole row would + // write back the description read above over an edit + // saved since. + active := !ep.Active + + return active, h.db.DB().Model(&ep). + Update("active", active).Error + }, + "failed to toggle entrypoint", + entrypointActivated, entrypointDeactivated, + ) +} diff --git a/internal/handlers/entrypoint_toggle_test.go b/internal/handlers/entrypoint_toggle_test.go new file mode 100644 index 0000000..77a2209 --- /dev/null +++ b/internal/handlers/entrypoint_toggle_test.go @@ -0,0 +1,95 @@ +package handlers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle +// which loaded the entrypoint before an edit of its description was +// saved does not write the old description back over the edit. The +// edit is submitted from a callback on the toggle's own read of the +// entrypoint, so it is saved after that read and before the toggle +// writes. +func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 30) + ep := seedEntrypoint(t, env.db, wh.ID) + require.True(t, ep.Active) + + router := chi.NewRouter() + router.Post( + "/hook/{sourceID}/entrypoints/{entrypointID}/edit", + env.handlers.HandleEntrypointEdit(), + ) + router.Post( + "/hook/{sourceID}/entrypoints/{entrypointID}/toggle", + env.handlers.HandleEntrypointToggle(), + ) + + // post submits one of the entrypoint's forms as the test user and + // returns the response's status code. + post := func(action string, form url.Values) int { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action, + strings.NewReader(form.Encode()), + ) + req.Header.Set( + "Content-Type", "application/x-www-form-urlencoded", + ) + + for _, c := range env.cookies { + req.AddCookie(c) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + return w.Code + } + + var ( + edited bool + editCode int + ) + + require.NoError(t, env.db.DB().Callback().Query(). + After("gorm:query"). + Register("test:edit_after_toggle_read", func(tx *gorm.DB) { + // Only the first read of an entrypoint, the toggle's, + // submits the edit. + if tx.Statement.Table != "entrypoints" || edited { + return + } + + edited = true + editCode = post( + "edit", url.Values{"description": {"Billing sender"}}, + ) + }), + ) + + require.Equal(t, http.StatusSeeOther, post("toggle", nil)) + require.Equal(t, http.StatusSeeOther, editCode) + + var stored database.Entrypoint + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", ep.ID).Error, + ) + assert.False(t, stored.Active) + assert.Equal(t, "Billing sender", stored.Description) +} diff --git a/internal/handlers/entrypoint_view.go b/internal/handlers/entrypoint_view.go index c679b3f..89200a1 100644 --- a/internal/handlers/entrypoint_view.go +++ b/internal/handlers/entrypoint_view.go @@ -1,6 +1,11 @@ package handlers import ( + "fmt" + "time" + + "github.com/dustin/go-humanize" + "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) @@ -11,6 +16,21 @@ type EntrypointView struct { Path string Description string Active bool + + // Events is how many events arrived on the entrypoint's URL within + // the webhook's retention period. LastEvent is when the newest + // event ever to arrive on it did, relative, and LastEventUTC the + // full time; both are empty when none ever did. + Events int64 + LastEvent string + LastEventUTC string +} + +// entrypointEvents is one entrypoint's count read by +// addEntrypointEvents. +type entrypointEvents struct { + EntrypointID string + Events int64 } // NewEntrypointViews projects entrypoints for rendering. @@ -32,3 +52,60 @@ func NewEntrypointViews( return views } + +// addEntrypointEvents fills in each view's event figures from the +// webhook's event database: when the last event arrived on its URL, +// from its EntrypointTotals row, and how many events arrived on it +// since the webhook's retention cutoff, counted in one query over the +// events' entrypoint_id index. Resubmitted copies did not arrive on +// the URL and are left out of both. +func addEntrypointEvents( + webhookDB *gorm.DB, + webhook *database.Webhook, + views []EntrypointView, + now time.Time, +) error { + ids := make([]string, len(views)) + byID := make(map[string]*EntrypointView, len(views)) + + for i := range views { + ids[i] = views[i].ID + byID[views[i].ID] = &views[i] + } + + var totals []database.EntrypointTotals + + err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error + if err != nil { + return fmt.Errorf("reading entrypoint totals: %w", err) + } + + query := webhookDB.Model(&database.Event{}). + Select("entrypoint_id, count(*) AS events"). + Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids) + + cutoff, finite := webhook.RetentionCutoff(now) + if finite { + query = query.Where("created_at >= ?", cutoff) + } + + var counts []entrypointEvents + + err = query.Group("entrypoint_id").Find(&counts).Error + if err != nil { + return fmt.Errorf("counting events by entrypoint: %w", err) + } + + for _, row := range totals { + view := byID[row.EntrypointID] + view.LastEvent = humanize.Time(row.LastEventAt) + view.LastEventUTC = + row.LastEventAt.UTC().Format(time.DateTime) + " UTC" + } + + for _, row := range counts { + byID[row.EntrypointID].Events = row.Events + } + + return nil +} diff --git a/internal/handlers/entrypoint_view_test.go b/internal/handlers/entrypoint_view_test.go new file mode 100644 index 0000000..1944e49 --- /dev/null +++ b/internal/handlers/entrypoint_view_test.go @@ -0,0 +1,197 @@ +package handlers_test + +import ( + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/session" +) + +// entrypointRow returns the part of a rendered webhook page from an +// entrypoint's URL to the next entrypoint's, which holds its figures. +func entrypointRow(t *testing.T, page, entrypointID string) string { + t.Helper() + + _, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`) + require.True(t, found) + + row, _, _ = strings.Cut(row, `id="entrypoint-url-`) + + return row +} + +// lastEventShown matches an entrypoint row's last event arriving at at. +func lastEventShown(at time.Time) string { + return `Last Event:\s*[^<]+` +} + +// eventsShown matches an entrypoint row's count of n events. +func eventsShown(n int) string { + return `Events Within Retention:\s*` + + strconv.Itoa(n) + `` +} + +// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint +// on the webhook page shows its own figures: how many events arrived +// through it within the webhook's retention period, leaving out one +// older than that, and when the newest arrived, or "never" for an +// entrypoint with none. +func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := &database.Webhook{ + UserID: deleteTestUserID, Name: "figures", RetentionDays: 7, + } + require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) + + webhookDB, err := dbMgr.GetDB(wh.ID) + require.NoError(t, err) + + entrypoint := func() *database.Entrypoint { + ep := &database.Entrypoint{ + WebhookID: wh.ID, Path: uuid.New().String(), Active: true, + } + require.NoError(t, + db.DB().Omit(clause.Associations).Create(ep).Error) + + return ep + } + + // event stores an event that arrived on ep's URL age ago and + // records it as ep's last event, as the receiver does. + event := func(ep *database.Entrypoint, age time.Duration) time.Time { + e := &database.Event{ + WebhookID: wh.ID, + EntrypointID: ep.ID, + Method: http.MethodPost, + } + e.CreatedAt = time.Now().Add(-age) + require.NoError(t, + webhookDB.Omit(clause.Associations).Create(e).Error) + require.NoError(t, database.AddEntrypointTotals(webhookDB, + database.EntrypointTotals{ + EntrypointID: ep.ID, LastEventAt: e.CreatedAt, + })) + + return e.CreatedAt + } + + busy, quiet, unused := entrypoint(), entrypoint(), entrypoint() + + event(busy, 8*24*time.Hour) // older than the 7 days kept + event(busy, 3*time.Hour) + busyLast := event(busy, time.Hour) + quietLast := event(quiet, 2*24*time.Hour) + + body := renderSourceDetailPage(t, h, sess, wh.ID) + + assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID)) + assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID)) + assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID)) + assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID)) + assert.Regexp(t, `Last Event:\s*never`, + entrypointRow(t, body, unused.ID)) + assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID)) +} + +// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks +// that once retention has removed every event that arrived on an +// entrypoint's URL, the entrypoint still shows when the last one +// arrived rather than "never". +func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention( + t *testing.T, +) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + log *logger.Logger + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr, &log) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := &database.Webhook{ + UserID: deleteTestUserID, Name: "swept", RetentionDays: 1, + } + require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) + + ep := seedEntrypoint(t, db, wh.ID) + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1) + arrived := events[0].CreatedAt + + statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour)) + statsPrune(t, db, dbMgr, log, webhookDB) + require.Empty(t, listEvents(t, webhookDB)) + + row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID) + assert.Regexp(t, lastEventShown(arrived), row) + assert.Regexp(t, eventsShown(0), row) +} + +// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a +// resubmitted copy, which did not arrive on the entrypoint's URL, +// changes neither the entrypoint's last event nor its count. +func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures( + t *testing.T, +) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + ep := seedEntrypoint(t, db, wh.ID) + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1) + arrived := events[0].CreatedAt + + require.Equal(t, http.StatusSeeOther, + postResubmit(t, h, sess, wh.ID, events[0].ID).Code) + require.Len(t, listEvents(t, webhookDB), 2) + + var totals database.EntrypointTotals + + require.NoError(t, webhookDB.Take(&totals).Error) + assert.True(t, arrived.Equal(totals.LastEventAt)) + + row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID) + assert.Regexp(t, lastEventShown(arrived), row) + assert.Regexp(t, eventsShown(1), row) +} diff --git a/internal/handlers/error_page_test.go b/internal/handlers/error_page_test.go new file mode 100644 index 0000000..1b87fe9 --- /dev/null +++ b/internal/handlers/error_page_test.go @@ -0,0 +1,53 @@ +package handlers_test + +import ( + "context" + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "sneak.berlin/go/webhooker/internal/handlers" +) + +// TestErrorPage_RenderFailureKeepsStatus proves that an error page +// which cannot render answers with the status it was reporting, as +// plain text, and is not attempted again: a page whose own render +// fails reaches the error page, and the error page failing as well +// ends there with the 500. +func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + app := newTestApp(t, &h) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + // .Status is an int, so asking it for a field fails the render. + failing := `{{.Status.Missing}}` + h.AddTemplateForTest("error.html", template.Must( + template.New("error").Parse(failing), + )) + h.AddTemplateForTest("failing.html", template.Must( + template.New("failing").Parse(`{{.Data.Missing}}`), + )) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/", nil, + ) + + w := httptest.NewRecorder() + h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "Not Found\n", w.Body.String()) + + w = httptest.NewRecorder() + h.RenderTemplateForTest(w, req, "failing.html", 0) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "Internal Server Error\n", w.Body.String()) +} diff --git a/internal/handlers/event_body.go b/internal/handlers/event_body.go index d46b9a5..603d145 100644 --- a/internal/handlers/event_body.go +++ b/internal/handlers/event_body.go @@ -15,16 +15,19 @@ import ( // eventBodyQuery reads one event's stored body as bytes. The cast // to blob is what makes the driver hand back the stored bytes // rather than a string conversion, so Content-Length taken from -// the result matches what goes on the wire. The soft-delete -// predicate is spelled out because Raw bypasses GORM's default -// scope, and it is what stops a reaped event still being -// downloadable. +// the result matches what goes on the wire. The retention reaper +// deletes event rows outright, so a reaped event is simply gone +// and the query finds no row. The deleted_at predicate repeats +// the soft-delete scope GORM adds to its own queries, which Raw +// bypasses; nothing soft-deletes an event, so today it excludes +// nothing. const eventBodyQuery = "SELECT cast(body as blob) " + "FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL" -// HandleEventBodyDownload serves one event's stored body in -// full, which the event log page cannot: it caps each rendered -// body at maxRenderedBodyBytes. +// HandleEventBodyDownload serves one event's stored body byte +// for byte, which the pages do not: they show it as escaped +// text, cut at maxRenderedBodyBytes in the lists of events, and +// leave a binary one out. // // The bytes are attacker-supplied — anyone who can reach the // public receiver chooses them — and this route hands them back @@ -52,7 +55,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc { // steered by a client. eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) if err != nil { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } @@ -103,21 +106,21 @@ func (h *Handlers) serveEventBody( eventID string, ) { if !h.dbMgr.DBExists(webhook.ID) { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { - h.serverError(w, "failed to get webhook database", err) + h.serverError(w, r, "failed to get webhook database", err) return } body, found, err := eventBody(webhookDB, webhook.ID, eventID) if err != nil { - h.serverError(w, "failed to read event body", err) + h.serverError(w, r, "failed to read event body", err) return } @@ -130,7 +133,7 @@ func (h *Handlers) serveEventBody( // row and the whole body is served, or it does not and the // response is a clean 404. if !found { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } diff --git a/internal/handlers/event_body_test.go b/internal/handlers/event_body_test.go index e4656ac..0ab4692 100644 --- a/internal/handlers/event_body_test.go +++ b/internal/handlers/event_body_test.go @@ -64,8 +64,8 @@ func fetchEventBody( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+url.PathEscape(sourceID)+ - "/logs/"+url.PathEscape(eventID)+"/body", + "/hook/"+url.PathEscape(sourceID)+ + "/events/"+url.PathEscape(eventID)+"/body", nil, ) @@ -405,10 +405,11 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) { // route. The body is read in one query before any header is // written, so a reaped event cannot produce a partial download: // it is a clean 404 with no Content-Length and no -// Content-Disposition. Both removals the codebase performs are -// covered — the reaper hard-deletes, and a soft-deleted row is -// excluded by the query's own deleted_at predicate rather than -// by GORM's default scope, which Raw bypasses. +// Content-Disposition. The reaper deletes event rows outright, +// which is the "hard deleted" case. The "soft deleted" case +// covers a row no code produces today: it only pins the query's +// own deleted_at predicate, the soft-delete condition Raw would +// otherwise skip. func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) { t.Parallel() @@ -490,7 +491,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload( page := renderSourceLogsPage(t, h, sess, big.ID) assert.Contains( t, page, - "/source/"+big.ID+"/logs/"+bigEvt.ID+"/body", + "/hook/"+big.ID+"/events/"+bigEvt.ID+"/body", ) small := seedWebhook(t, db) @@ -501,6 +502,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload( page = renderSourceLogsPage(t, h, sess, small.ID) assert.NotContains( t, page, - "/source/"+small.ID+"/logs/"+smallEvt.ID+"/body", + "/hook/"+small.ID+"/events/"+smallEvt.ID+"/body", ) } diff --git a/internal/handlers/event_body_view.go b/internal/handlers/event_body_view.go new file mode 100644 index 0000000..d1e8b11 --- /dev/null +++ b/internal/handlers/event_body_view.go @@ -0,0 +1,168 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "unicode" + "unicode/utf8" +) + +// maxRenderedBodyBytes is the most of one event's body that the +// recent events on a webhook's page and the event log show; a larger +// body is cut there and shown whole only on the event's own page. +// Bodies come from the unauthenticated receiver under its 1 MB cap, +// and renderTemplate buffers a whole page before writing it, so a list +// of events cannot show every body whole. +const maxRenderedBodyBytes = 32 << 10 + +// maxInlineBodyLines is the most lines a body is shown at its full +// height with. A body with more lines, or larger than +// maxRenderedBodyBytes, is shown in a box of fixed height that +// scrolls, so that it does not make the page huge. +const maxInlineBodyLines = 200 + +// maxIndentDepth is how deeply a JSON body's objects and arrays may +// nest for it to be indented at all; a deeper one is shown as received. +// Each level indents every line inside it two more spaces, so 10 KB of +// nested brackets would indent to some 50 MB; within this depth a body +// grows at most 35 times. +const maxIndentDepth = 16 + +// A JSON body is shown pretty-printed only when that makes it at most +// maxIndentGrowth times its size plus indentAllowance bytes, and +// otherwise as received, so that indenting does not undo +// maxRenderedBodyBytes. The allowance keeps a small nested body +// pretty-printed. +const ( + maxIndentGrowth = 4 + indentAllowance = 1 << 10 +) + +// jsonIndent is the indent of a pretty-printed JSON body. +const jsonIndent = " " + +// BodyView is an event's body as the pages show it. newBodyView +// decides it and templates/event_body.html shows it, the same way in +// the recent events on a webhook's page, in the event log and on the +// event's own page. +type BodyView struct { + // EventURL is the event's own page. The stored body downloads + // from EventURL/body. + EventURL string + + // Text is the body as shown, pretty-printed when it is JSON. + Text string + + // Size is the stored body's size in bytes, and ShownBytes how + // many of them Text holds when Cut. + Size int64 + ShownBytes int + + // Cut reports that Text is only the start of the body. + Cut bool + + // Binary reports a body that is not text. It is not shown. + Binary bool + + // Scroll reports a body to show in a box that scrolls. + Scroll bool +} + +// newBodyView decides how to show an event's body. body is the +// stored body, or its first maxRenderedBodyBytes when only those were +// read, and size is the stored body's size. +func newBodyView(eventURL string, body []byte, size int64) BodyView { + v := BodyView{EventURL: eventURL, Size: size} + + if size > int64(len(body)) { + v.Cut = true + body = trimPartialRune(body) + v.ShownBytes = len(body) + } + + // html/template shows invalid UTF-8 as replacement characters, + // and a browser shows a control character other than tab, line + // feed and carriage return as a box or not at all, so a body + // holding either is not text. + isControl := func(r rune) bool { + return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' + } + + if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 { + v.Binary = true + + return v + } + + // A cut JSON document is no longer valid JSON. + if !v.Cut { + body = indentJSON(body) + } + + // The page shows a carriage return, a line feed, or the two + // together as one line break. A final one ends the last line + // rather than starting another. + text := bytes.TrimSuffix(body, []byte("\n")) + text = bytes.TrimSuffix(text, []byte("\r")) + breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) - + bytes.Count(text, []byte("\r\n")) + lines := breaks + 1 + + v.Text = string(body) + v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes + + return v +} + +// indentJSON returns body pretty-printed when it is a JSON document, +// and unchanged when it is not, nests deeper than maxIndentDepth, or +// would grow past maxIndentGrowth times its size plus indentAllowance +// bytes. +func indentJSON(body []byte) []byte { + if !json.Valid(body) || !indentFits(body) { + return body + } + + var out bytes.Buffer + + err := json.Indent(&out, body, "", jsonIndent) + if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance { + return body + } + + return out.Bytes() +} + +// indentFits reports whether the objects and arrays of the JSON +// document body nest at most maxIndentDepth deep. +func indentFits(body []byte) bool { + depth := 0 + + dec := json.NewDecoder(bytes.NewReader(body)) + + // A number too large for a float64 is still valid JSON. + dec.UseNumber() + + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return true + } + + if err != nil { + return false + } + + switch tok { + case json.Delim('{'), json.Delim('['): + depth++ + if depth > maxIndentDepth { + return false + } + case json.Delim('}'), json.Delim(']'): + depth-- + } + } +} diff --git a/internal/handlers/event_body_view_test.go b/internal/handlers/event_body_view_test.go new file mode 100644 index 0000000..e7608ec --- /dev/null +++ b/internal/handlers/event_body_view_test.go @@ -0,0 +1,176 @@ +package handlers_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "sneak.berlin/go/webhooker/internal/handlers" +) + +// bodyView is how the pages would show body, stored whole. +func bodyView(body string) handlers.BodyView { + return handlers.NewBodyViewForTest([]byte(body), int64(len(body))) +} + +// lines is n lines of text, without a newline after the last. +func lines(n int) string { + return strings.TrimSuffix(strings.Repeat("line\n", n), "\n") +} + +// TestNewBodyView_FormatsValidJSON proves a JSON body is shown +// pretty-printed, whatever its content type, with its keys in +// the order they arrived. +func TestNewBodyView_FormatsValidJSON(t *testing.T) { + t.Parallel() + + v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`) + + assert.Equal(t, []string{ + `{`, + ` "b": 1,`, + ` "a": [`, + ` true,`, + ` null,`, + ` "x"`, + ` ],`, + ` "c": {}`, + `}`, + }, strings.Split(v.Text, "\n")) + assert.False(t, v.Scroll) +} + +// TestNewBodyView_FormatsNestedJSON proves a small document with a +// few levels of nesting is pretty-printed. +func TestNewBodyView_FormatsNestedJSON(t *testing.T) { + t.Parallel() + + v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`) + + assert.Equal(t, []string{ + `{`, + ` "data": [`, + ` [`, + ` 1,`, + ` 2,`, + ` 3`, + ` ],`, + ` [`, + ` 4,`, + ` 5,`, + ` 6`, + ` ]`, + ` ]`, + `}`, + }, strings.Split(v.Text, "\n")) +} + +// TestNewBodyView_InvalidJSONAsReceived proves a body that is not +// a JSON document is shown exactly as it arrived. +func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) { + t.Parallel() + + for _, body := range []string{ + `{"a":1,`, + `{"a":1} {"b":2}`, + "plain text\n indented", + } { + assert.Equal(t, body, bodyView(body).Text) + } +} + +// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested +// more than 16 levels deep is shown as it arrived. 10 KB of nested +// arrays would indent to some 50 MB. +func TestNewBodyView_DeepJSONAsReceived(t *testing.T) { + t.Parallel() + + nested := func(depth int) string { + return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth) + } + + assert.NotEqual(t, nested(16), bodyView(nested(16)).Text) + assert.Equal(t, nested(17), bodyView(nested(17)).Text) + + body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + + assert.Equal(t, body, bodyView(body).Text) +} + +// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that +// pretty-printing would make more than four times its size plus 1 KiB +// is shown as it arrived, however shallow: each short element eight +// levels deep gets a line indented sixteen spaces. +func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) { + t.Parallel() + + numbers := func(n int) string { + return strings.Repeat("[", 8) + + strings.TrimSuffix(strings.Repeat("1,", n), ",") + + strings.Repeat("]", 8) + } + + assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text) + assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text) +} + +// TestNewBodyView_ScrollsPast200Lines proves a body is shown at +// its full height up to 200 lines and in the scrolling box past +// them, counting the lines after formatting. +func TestNewBodyView_ScrollsPast200Lines(t *testing.T) { + t.Parallel() + + assert.False(t, bodyView(lines(200)).Scroll) + assert.True(t, bodyView(lines(201)).Scroll) + + // A final newline ends the last line rather than starting another. + assert.False(t, bodyView(lines(200)+"\n").Scroll) + assert.True(t, bodyView(lines(201)+"\n").Scroll) + + // The page shows a carriage return, a line feed, or the two + // together as one line break. + assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll) + assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll) + + // One line as received, 201 once formatted: the brackets and + // 199 elements. + numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]" + + assert.NotContains(t, numbers, "\n") + assert.True(t, bodyView(numbers).Scroll) +} + +// TestNewBodyView_LargeBodyScrolls proves a body larger than the +// cap of the lists of events is shown in the scrolling box +// however few lines it has, on the event's own page as in the +// lists. +func TestNewBodyView_LargeBodyScrolls(t *testing.T) { + t.Parallel() + + assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll) + assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll) +} + +// TestNewBodyView_BinaryNotShown proves a body that is not text +// is never shown: one that is not valid UTF-8, or that holds a +// control character other than tab, line feed and carriage return. +func TestNewBodyView_BinaryNotShown(t *testing.T) { + t.Parallel() + + for _, body := range []string{ + "\xff\xfe\xfd", + "a\x00b", + // A small protobuf message: valid UTF-8, but control bytes. + "\x08\x01\x12\x03abc", + "\x1b[31mred\x1b[0m", + "a\x7fb", + } { + v := bodyView(body) + + assert.True(t, v.Binary, "%q", body) + assert.Empty(t, v.Text) + } + + assert.False(t, bodyView("snow "+snowman).Binary) + assert.False(t, bodyView("a\tb\r\nc\n").Binary) +} diff --git a/internal/handlers/event_detail.go b/internal/handlers/event_detail.go new file mode 100644 index 0000000..a64fc19 --- /dev/null +++ b/internal/handlers/event_detail.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "math" + "net/http" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleEventDetail shows one event on its own page: its details, +// its whole body and every delivery of it. The page reads the +// event's body whole, which the receiver caps at 1 MB. +func (h *Handlers) HandleEventDetail() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + webhook, ok := h.ownedWebhook(w, r) + if !ok { + return + } + + if !h.dbMgr.DBExists(webhook.ID) { + h.renderError(w, r, http.StatusNotFound) + + return + } + + webhookDB, err := h.dbMgr.GetDB(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to get webhook database", err) + + return + } + + var rows []eventLogRow + + err = webhookDB.Model(&database.Event{}). + Select(eventColumns). + Where( + "id = ? AND webhook_id = ?", + chi.URLParam(r, "eventID"), webhook.ID, + ). + Limit(1). + Find(&rows).Error + if err != nil { + h.serverError(w, r, "failed to load event", err) + + return + } + + if len(rows) == 0 { + h.renderError(w, r, http.StatusNotFound) + + return + } + + targets, err := h.loadTargetMap(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to load targets", err) + + return + } + + // The page shows every request header. + views, ok := h.eventLogViews( + w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt, + ) + if !ok { + return + } + + h.renderTemplate(w, r, "event_detail.html", map[string]any{ + tmplKeyWebhook: &webhook, + "Event": views[0], + }) + } +} diff --git a/internal/handlers/event_detail_test.go b/internal/handlers/event_detail_test.go new file mode 100644 index 0000000..ff6d68f --- /dev/null +++ b/internal/handlers/event_detail_test.go @@ -0,0 +1,152 @@ +package handlers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// serveEventPage runs the real event page handler as the test user +// for the given webhook and event ids. +func serveEventPage( + t *testing.T, + h *handlers.Handlers, + sess *session.Session, + webhookID, eventID string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + "/hook/"+webhookID+"/events/"+eventID, + nil, + ) + + for _, c := range authenticatedCookies( + t, sess, deleteTestUserID, deleteTestUsername, + ) { + req.AddCookie(c) + } + + rctx := chi.NewRouteContext() + rctx.URLParams.Add(paramSourceID, webhookID) + rctx.URLParams.Add(paramEventID, eventID) + + req = req.WithContext( + context.WithValue(req.Context(), chi.RouteCtxKey, rctx), + ) + + w := httptest.NewRecorder() + h.HandleEventDetail().ServeHTTP(w, req) + + return w +} + +// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the +// event's page shows its details, its whole body even past the cap +// of the lists of events, pretty-printed and in the scrolling box, +// and each delivery with its status and attempts. +func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) + + const sentinel = "TAIL-SENTINEL-5b2e" + + body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) + + `","tail":"` + sentinel + `"}` + event := f.event(t, contentTypeJSON, body, time.Now()) + f.attempt(t, f.delivery( + t, event, target.ID, database.DeliveryStatusFailed, + ), http.StatusBadGateway, time.Second) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + page := w.Body.String() + + assert.Contains(t, page, event.ID) + assert.Contains(t, page, contentTypeJSON) + assert.Contains(t, page, strconv.Itoa(len(body))+" bytes") + assert.Contains(t, page, "{\n "pad": "xxx") + assert.Contains(t, page, ""tail": ""+sentinel+""\n}") + assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`) + assert.NotContains(t, page, "Showing the first") + assert.Contains(t, page, target.Name) + assert.Contains(t, page, ">failed") + assert.Contains(t, page, "Status: 502") +} + +// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's +// page links to its original's page, and the original's page says +// it was resubmitted. +func TestHandleEventDetail_ResubmitLinks(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + original := f.event(t, contentTypeJSON, "{}", time.Now()) + + cp := &database.Event{ + WebhookID: f.webhook.ID, + Method: http.MethodPost, + Body: "{}", + ContentType: contentTypeJSON, + ResubmittedFromID: &original.ID, + } + require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains( + t, w.Body.String(), + `href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`, + ) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "as 1 new event<") +} + +// TestHandleEventDetail_UnknownEventNotFound proves the page is a +// 404 for an event that does not exist and for one that belongs to +// another webhook. +func TestHandleEventDetail_UnknownEventNotFound(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + mine := seedWebhook(t, db) + theirs := seedWebhook(t, db) + + seedEventWithBody(t, dbMgr, mine.ID, "{}") + elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}") + + for _, id := range []string{"no-such-event", elsewhere.ID} { + w := serveEventPage(t, h, sess, mine.ID, id) + assert.Equal(t, http.StatusNotFound, w.Code, id) + } +} diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go new file mode 100644 index 0000000..63a5d7c --- /dev/null +++ b/internal/handlers/event_log.go @@ -0,0 +1,621 @@ +package handlers + +import ( + "net/http" + "slices" + "time" + + "github.com/dustin/go-humanize" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// DeliveryView is the display-safe projection of a delivery +// for the event log page. Its target is a TargetView, so the +// stored configuration blob — which holds the target's +// credential — has no path to the template. +type DeliveryView struct { + ID string + Status database.DeliveryStatus + Target delivery.TargetView + + // Replay is set on a delivery the Replay action created. + Replay bool + + // Created is how long ago the delivery was created, and + // CreatedUTC the full timestamp the page shows on hover. + Created string + CreatedUTC string + + // Results is this delivery's attempts in attempt order, + // bounded by maxRenderedAttempts. Without them a failure + // renders as the status word alone and says nothing about + // why. + Results []DeliveryResultView + + // AttemptCount is how many attempts were recorded, which + // is more than len(Results) once the middle was dropped. + AttemptCount int + + // AttemptsOmitted is how many attempts were dropped from + // the middle of Results. The page must show it, or the + // bound would hide history rather than fold it. + AttemptsOmitted int + + // Paused is set while the delivery is retrying and its + // target's circuit breaker is open, and nil otherwise. + Paused *PausedView +} + +// eventLogTarget is what the event log needs to know about +// one target: the display-safe view its template renders, and +// the redactor that keeps that target's own credential out of +// the text its remote peer chose. The two are kept together +// so a caller cannot pick up one without the other, and apart +// from TargetView so the secrets never reach a template. +type eventLogTarget struct { + View delivery.TargetView + Redactor delivery.Redactor +} + +// The event log's show query parameter and its two values: the events +// with a failed delivery, and those with a delivery still pending or +// retrying. +const ( + showParam = "show" + showFailed = "failed" + showPending = "pending" +) + +// HandleSourceLogs shows the request/response logs for a +// webhook. +func (h *Handlers) HandleSourceLogs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + webhook, ok := h.ownedWebhook(w, r) + if !ok { + return + } + + targets, err := h.loadTargetMap(webhook.ID) + if err != nil { + // Without the map every delivery renders through a + // zero redactor, so failing the page is the only + // safe answer. + h.serverError(w, r, "failed to load targets", err) + + return + } + + // Any other value of show lists every event, as no value + // does. + show := r.URL.Query().Get(showParam) + + statuses := eventLogStatuses(show) + if statuses == nil { + show = "" + } + + evts, total, ok := h.loadEventsWithDeliveries( + w, r, webhook, targets, statuses, + ) + if !ok { + return + } + + failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to count events", err) + + return + } + + data := map[string]any{ + tmplKeyWebhook: &webhook, + "Events": evts, + "TotalEvents": total, + "Show": show, + "FailedEvents": failed, + "PendingEvents": pending, + } + + h.renderTemplate(w, r, "source_logs.html", data) + } +} + +// loadTargetMap loads targets into a map of display-safe +// views keyed by target ID, each paired with its redactor. +// The projection happens here so that no caller can hand a +// raw target, configuration blob and all, to a template: the +// raw rows do not leave this function. +// +// The load is Unscoped because deleting a target only soft +// deletes the row while its deliveries survive in the +// per-webhook database. Both halves of the map need those rows: +// a scoped load leaves an old delivery with a zero redactor, +// which renders its response bodies unredacted, and with a zero +// view, which renders its target as a blank name. +// +// This map is historical display only. It is built for the event +// log and an event's own page, and reaches nothing but +// DeliveryView.Target: the target list on the source detail page, +// the edit form and the replay path each resolve targets +// themselves, and a deleted row is refused there as before. +func (h *Handlers) loadTargetMap( + webhookID string, +) (map[string]eventLogTarget, error) { + var targets []database.Target + + err := h.db.DB().Unscoped().Where( + "webhook_id = ?", webhookID, + ).Find(&targets).Error + if err != nil { + return nil, err + } + + targetMap := make( + map[string]eventLogTarget, len(targets), + ) + + for i := range targets { + targetMap[targets[i].ID] = eventLogTarget{ + Redactor: delivery.NewRedactor(&targets[i]), + } + } + + // The views come from NewTargetViews rather than being + // rebuilt here, so the masking rules stay in one place and a + // deleted target's configuration is masked by the same code + // that masks a live one's. + for _, v := range delivery.NewTargetViews(targets) { + entry := targetMap[v.ID] + entry.View = v + targetMap[v.ID] = entry + } + + return targetMap, nil +} + +// loadEventsWithDeliveries loads the recentEventLimit newest events +// and their deliveries from the per-webhook database, and the total +// number of events stored. Given delivery statuses, both cover only +// the events with a delivery in one of them. Events come back as +// capped projections rather than database.Event rows: see +// eventLogColumns for why the cut happens in SQL. +// +// The bool reports whether the load succeeded. It is false +// once this has answered the request with an error, and the +// caller must then render nothing further. +func (h *Handlers) loadEventsWithDeliveries( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, + targetMap map[string]eventLogTarget, + statuses []database.DeliveryStatus, +) ([]EventLogView, int64, bool) { + if !h.dbMgr.DBExists(webhook.ID) { + return nil, 0, true + } + + webhookDB, err := h.dbMgr.GetDB(webhook.ID) + if err != nil { + h.serverError( + w, r, "failed to get webhook database", err, + ) + + return nil, 0, false + } + + rows, totalEvents, err := loadEventLogRows( + webhookDB, webhook.ID, statuses, + ) + if err != nil { + h.serverError(w, r, "failed to load events", err) + + return nil, 0, false + } + + result, ok := h.eventLogViews( + w, r, webhookDB, webhook.ID, rows, targetMap, + maxRenderedBodyBytes, + ) + + return result, totalEvents, ok +} + +// eventLogViews projects loaded events for rendering, each with +// its deliveries, how many times it has been resubmitted and the +// entrypoint it arrived at (for a resubmitted copy, the one the +// request it copies arrived at), and with its request headers only +// when their text holds at most maxHeaderBytes. Like +// loadEventsWithDeliveries, it reports false once it has answered +// the request with an error. +func (h *Handlers) eventLogViews( + w http.ResponseWriter, + r *http.Request, + webhookDB *gorm.DB, + webhookID string, + rows []eventLogRow, + targetMap map[string]eventLogTarget, + maxHeaderBytes int, +) ([]EventLogView, bool) { + result := make([]EventLogView, len(rows)) + eventDeliveries := make([][]database.Delivery, len(rows)) + + var deliveryIDs []string + + eventIDs := make([]string, len(rows)) + + for i := range rows { + result[i] = rows[i].view(webhookID, maxHeaderBytes) + eventIDs[i] = rows[i].ID + + webhookDB.Where( + "event_id = ?", rows[i].ID, + ).Find(&eventDeliveries[i]) + + for j := range eventDeliveries[i] { + deliveryIDs = append( + deliveryIDs, eventDeliveries[i][j].ID, + ) + } + } + + attempts, err := h.loadDeliveryResults( + webhookDB, deliveryIDs, + ) + if err != nil { + h.serverError( + w, r, "failed to load delivery attempts", err, + ) + + return nil, false + } + + resubmits, err := resubmitCounts(webhookDB, eventIDs) + if err != nil { + h.serverError( + w, r, "failed to count event resubmissions", err, + ) + + return nil, false + } + + entrypoints, err := h.entrypointNames(webhookID) + if err != nil { + h.serverError(w, r, "failed to load entrypoints", err) + + return nil, false + } + + for i := range rows { + result[i].Deliveries = h.newDeliveryViews( + eventDeliveries[i], targetMap, attempts, + ) + result[i].ResubmitCount = resubmits[rows[i].ID] + + name, ok := entrypoints[rows[i].EntrypointID] + if !ok { + name = "deleted entrypoint" + } + + result[i].Entrypoint = name + } + + return result, true +} + +// loadEventLogRows reads the event log projection of the +// recentEventLimit newest events, newest first, and the total number +// of events stored, both narrowed by statuses as eventsWithStatus +// narrows them. +func loadEventLogRows( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) ([]eventLogRow, int64, error) { + totalEvents, err := countEventsWithStatus( + webhookDB, webhookID, statuses, + ) + if err != nil { + return nil, 0, err + } + + var rows []eventLogRow + + err = eventsWithStatus(webhookDB, webhookID, statuses).Select( + eventLogColumns, + maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes, + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error + + return rows, totalEvents, err +} + +// eventLogStatuses returns the delivery statuses the event log's show +// value lists events by, or nil for one that lists every event. +func eventLogStatuses(show string) []database.DeliveryStatus { + switch show { + case showFailed: + return []database.DeliveryStatus{database.DeliveryStatusFailed} + case showPending: + return []database.DeliveryStatus{ + database.DeliveryStatusPending, + database.DeliveryStatusRetrying, + } + default: + return nil + } +} + +// eventsWithStatus selects the webhook's events, or, given statuses, +// the recentEventLimit newest of those with at least one delivery in +// one of them. +// +// Given statuses, its cost follows the matching deliveries. SQLite +// never reorders a CROSS JOIN, so it reads each join's left side +// first: the distinct event IDs of the matching deliveries, through +// idx_deliveries_status; then each of those events by ID, sorted to +// keep the newest; then the rows of only the events kept, so no other +// event's body is read. With a plain "id IN (matching deliveries)" +// condition instead, SQLite, which keeps no statistics on these +// tables, walks every event newest first. +func eventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) *gorm.DB { + if statuses == nil { + return webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ) + } + + matching := webhookDB.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", statuses) + + newest := webhookDB.Table("(?) AS matching", matching). + Joins("CROSS JOIN events ON events.id = matching.event_id"). + Where( + "events.webhook_id = ? AND events.deleted_at IS NULL", + webhookID, + ). + Order("events.created_at DESC").Limit(recentEventLimit). + Select("events.id AS event_id") + + return webhookDB.Table("(?) AS newest", newest). + Joins("CROSS JOIN events ON events.id = newest.event_id") +} + +// countEventsWithStatus counts the webhook's events with at least one +// delivery in one of the statuses, or every event when statuses is +// nil. Given statuses, it counts the distinct events of the matching +// deliveries and reads nothing but those deliveries, through +// idx_deliveries_status, where counting the events would read every +// event row. That is the same number, because retention deletes an +// event's deliveries with it. +func countEventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) (int64, error) { + var count int64 + + if statuses == nil { + err := webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ).Count(&count).Error + + return count, err + } + + err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). + Where("status IN ?", statuses).Count(&count).Error + + return count, err +} + +// countFailedAndPendingEvents returns how many of the webhook's events +// the event log lists when it shows only those with a failed delivery, +// and when it shows only those with a delivery pending or retrying. +func (h *Handlers) countFailedAndPendingEvents( + webhookID string, +) (int64, int64, error) { + if !h.dbMgr.DBExists(webhookID) { + return 0, 0, nil + } + + webhookDB, err := h.dbMgr.GetDB(webhookID) + if err != nil { + return 0, 0, err + } + + failed, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showFailed), + ) + if err != nil { + return 0, 0, err + } + + pending, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showPending), + ) + if err != nil { + return 0, 0, err + } + + return failed, pending, nil +} + +// resubmitCounts reports, for each of the page's events, how many +// events have been resubmitted from it. +// +// One grouped query covers the page rather than one query per event. +// The page shows at most recentEventLimit events, far below SQLite's +// bound parameter ceiling, so it needs no chunking as the delivery +// result load does. +func resubmitCounts( + webhookDB *gorm.DB, eventIDs []string, +) (map[string]int, error) { + counts := make(map[string]int, len(eventIDs)) + + if len(eventIDs) == 0 { + return counts, nil + } + + var rows []struct { + ResubmittedFromID string + Total int + } + + err := webhookDB.Model(&database.Event{}). + Select("resubmitted_from_id, count(*) AS total"). + Where("resubmitted_from_id IN ?", eventIDs). + Group("resubmitted_from_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + counts[row.ResubmittedFromID] = row.Total + } + + return counts, nil +} + +// deliveryIDChunkSize bounds how many delivery IDs go into one +// IN clause. SQLite refuses a statement carrying more than +// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a +// page holds one delivery per target per event, so a webhook +// with enough targets would turn the whole query into an error +// and the page into zero attempts. +const deliveryIDChunkSize = 500 + +// loadDeliveryResults loads the recorded attempts for the +// page's deliveries, keyed by delivery ID. +// +// Each response body is cut by SQLite rather than in Go, for +// the reason deliveryResultColumns gives. How many attempts a +// delivery has is the target's MaxRetries, which the +// authenticated operator sets; how many of them reach the page +// is bounded again by maxRenderedAttempts. +func (h *Handlers) loadDeliveryResults( + webhookDB *gorm.DB, + deliveryIDs []string, +) (map[string][]deliveryResultRow, error) { + byDelivery := make(map[string][]deliveryResultRow) + + for chunk := range slices.Chunk( + deliveryIDs, deliveryIDChunkSize, + ) { + var rows []deliveryResultRow + + err := webhookDB.Model( + &database.DeliveryResult{}, + ).Select( + deliveryResultColumns, maxRenderedResponseBytes, + ).Where( + "delivery_id IN ?", chunk, + ).Order("attempt_num ASC").Find(&rows).Error + if err != nil { + // Returning what was loaded so far renders the + // deliveries in the failed chunk as never having run, + // which is indistinguishable from ones that really + // never ran. The page fails instead. + return nil, err + } + + for i := range rows { + byDelivery[rows[i].DeliveryID] = append( + byDelivery[rows[i].DeliveryID], rows[i], + ) + } + } + + return byDelivery, nil +} + +// newDeliveryViews projects deliveries for rendering, +// resolving each one's target to its display-safe view and +// each one's attempts through that target's redactor. A +// retrying delivery also reads its target's circuit breaker. +func (h *Handlers) newDeliveryViews( + deliveries []database.Delivery, + targetMap map[string]eventLogTarget, + attempts map[string][]deliveryResultRow, +) []DeliveryView { + views := make([]DeliveryView, len(deliveries)) + + for i := range deliveries { + target := targetMap[deliveries[i].TargetID] + rows := attempts[deliveries[i].ID] + created := deliveries[i].CreatedAt + + results, omitted := renderedAttempts( + rows, target.Redactor, + ) + + views[i] = DeliveryView{ + ID: deliveries[i].ID, + Status: deliveries[i].Status, + Target: target.View, + Replay: deliveries[i].Replay, + Created: humanize.Time(created), + CreatedUTC: created.UTC().Format(time.DateTime) + " UTC", + Results: results, + AttemptCount: len(rows), + AttemptsOmitted: omitted, + } + + if deliveries[i].Status == database.DeliveryStatusRetrying { + views[i].Paused = h.deliveryPausedView( + deliveries[i].TargetID, rows, + ) + } + } + + return views +} + +// maxRenderedAttempts bounds how many of one delivery's +// attempts the page renders. Past it the middle is dropped and +// counted, keeping the first attempts and the last ones: how +// the delivery started failing and how it ended are what a +// reader needs, and the count says plainly that the rest was +// dropped rather than never recorded. +const ( + renderedAttemptsHead = 10 + renderedAttemptsTail = 10 + maxRenderedAttempts = renderedAttemptsHead + + renderedAttemptsTail +) + +// renderedAttempts projects a delivery's attempts through the +// target's redactor, at most maxRenderedAttempts of them, and +// reports how many it dropped. +func renderedAttempts( + rows []deliveryResultRow, + redactor delivery.Redactor, +) ([]DeliveryResultView, int) { + omitted := 0 + + if len(rows) > maxRenderedAttempts { + omitted = len(rows) - maxRenderedAttempts + + kept := make( + []deliveryResultRow, 0, maxRenderedAttempts, + ) + kept = append(kept, rows[:renderedAttemptsHead]...) + kept = append( + kept, rows[len(rows)-renderedAttemptsTail:]..., + ) + rows = kept + } + + views := make([]DeliveryResultView, len(rows)) + for i := range rows { + views[i] = rows[i].view(redactor) + } + + return views, omitted +} diff --git a/internal/handlers/event_log_times_test.go b/internal/handlers/event_log_times_test.go new file mode 100644 index 0000000..7066c95 --- /dev/null +++ b/internal/handlers/event_log_times_test.go @@ -0,0 +1,52 @@ +package handlers_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestEventLog_TimesCarryTheirZone proves that the event log shows +// when an event arrived, and that it and the event's page show when +// each delivery was created and each attempt recorded: each as how +// long ago, with the full UTC time on hover. +func TestEventLog_TimesCarryTheirZone(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) + + now := time.Now().UTC().Truncate(time.Second) + receivedAt := now.Add(-3 * time.Hour) + createdAt := now.Add(-90 * time.Minute) + ranAt := now.Add(-30 * time.Minute) + + event := f.event(t, contentTypeJSON, "{}", receivedAt) + dlv := f.deliveryQueuedAt( + t, event, target.ID, database.DeliveryStatusDelivered, createdAt, + ) + f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt)) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + eventPage := w.Body.String() + eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + received := receivedAt.Format(time.DateTime) + assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago`) + assert.NotContains(t, eventLog, received+"", + "an event's time must not be written without its zone") + assert.Contains(t, eventPage, received+" UTC") + + for _, page := range []string{eventLog, eventPage} { + assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+ + ` UTC">created 1 hour ago`) + assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+ + ` UTC">30 minutes ago`) + } +} diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 5cfc0d1..23f1129 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -1,50 +1,80 @@ package handlers import ( + "encoding/json" + "net/http" + "slices" + "strings" "time" "unicode/utf8" -) -// maxRenderedBodyBytes caps how many bytes of a stored event -// body reach the event log page. Bodies come from the -// unauthenticated receiver under the 1 MB ingest cap and -// renderTemplate buffers a whole page before writing it, so -// an uncapped page of paginationPerPage events is tens of -// megabytes of resident memory per concurrent viewer. -const maxRenderedBodyBytes = 8192 + "github.com/dustin/go-humanize" + "sneak.berlin/go/webhooker/internal/database" +) // eventLogColumns is the event log's projection. The casts to // blob are load-bearing: they make substr and length count // bytes rather than characters, so the cap bounds the page in // bytes whatever the payload's encoding. Cutting in SQLite // rather than in Go is the point of the projection — an -// oversized body never becomes a Go string at all. +// oversized body, query string or set of request headers never +// becomes a Go string at all. const eventLogColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, " + + "resubmitted_from_id, entrypoint_id, " + + "substr(cast(raw_query as blob), 1, ?) AS raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, " + + "substr(cast(headers as blob), 1, ?) AS headers, " + + "length(cast(headers as blob)) AS headers_bytes, " + "substr(cast(body as blob), 1, ?) AS body, " + "length(cast(body as blob)) AS body_bytes" +// eventColumns is eventLogColumns for the event's own page, which +// shows the whole body, the whole query string and every request +// header. +const eventColumns = "id, created_at, method, content_type, " + + "resubmitted_from_id, entrypoint_id, raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, headers, " + + "length(cast(headers as blob)) AS headers_bytes, " + + "cast(body as blob) AS body, " + + "length(cast(body as blob)) AS body_bytes" + // EventLogView is the display-safe projection of an event for -// the event log page, alongside DeliveryView and TargetView. -// It carries a capped body plus the true stored size, so the -// page can mark a body as truncated without ever holding the -// whole thing. +// the event log page and the event's own page, alongside +// DeliveryView and TargetView. type EventLogView struct { ID string - CreatedAt time.Time Method string ContentType string - // Body holds at most maxRenderedBodyBytes bytes of the - // stored body. - Body string + // Received is how long ago the event arrived, and ReceivedUTC + // the full timestamp. + Received string + ReceivedUTC string - // BodyBytes is the true size of the stored body. - BodyBytes int64 + Body BodyView - // BodyTruncated reports that the stored body was larger - // than the cap, so the page owes the reader a marker. - BodyTruncated bool + // Entrypoint names the entrypoint the event arrived at. A + // resubmitted copy, even a copy of a copy, did not arrive; it + // names the one the request it copies arrived at. The name is + // the entrypoint's description, "Entrypoint" when it has none, + // or "deleted entrypoint", never its URL, which is the + // entrypoint's secret. + Entrypoint string + + // RawQuery is the query string the event arrived with. + // RawQueryCut reports one left out, RawQuery then empty, because + // it holds more than maxRenderedBodyBytes; only the event log + // leaves it out. + RawQuery string + RawQueryCut bool + + // Headers is the event's request headers as text, one + // "Name: value" line per value, sorted by name. HeadersCut + // reports headers left out because they hold more than + // maxRenderedBodyBytes, stored or as text; only the event log + // leaves them out. + Headers string + HeadersCut bool // ResubmittedFromID names the event this one was copied // from, empty for an event that arrived on the receiver. @@ -65,55 +95,133 @@ func (v EventLogView) ResubmittedFrom() bool { return v.ResubmittedFromID != "" } -// BodyShownBytes is how many body bytes the page is actually -// rendering, which the truncation marker reports beside the -// true size. -func (v EventLogView) BodyShownBytes() int { - return len(v.Body) -} - -// eventLogRow is one row of the event log projection. Its -// body column arrives already cut to the cap by SQLite, with -// the true size beside it. +// eventLogRow is one row of the event log projection, or of +// eventColumns. In the event log its query string, headers and +// body columns arrive already cut to the cap by SQLite, each with +// its true size beside it. type eventLogRow struct { ID string CreatedAt time.Time Method string ContentType string ResubmittedFromID *string + EntrypointID string + RawQuery string + RawQueryBytes int64 + Headers string + HeadersBytes int64 Body []byte BodyBytes int64 } -// view projects a loaded row for rendering. -func (r *eventLogRow) view() EventLogView { - body := r.Body - truncated := r.BodyBytes > int64(len(body)) - - // Only a cut body can have been left mid-sequence by - // this query. A whole body is passed through exactly as - // stored, however malformed. - if truncated { - body = trimPartialRune(body) - } - +// view projects a loaded row of the webhook's events for +// rendering. It shows the request headers when the row holds them +// whole and their text holds at most maxHeaderBytes. +func (r *eventLogRow) view( + webhookID string, maxHeaderBytes int, +) EventLogView { var from string if r.ResubmittedFromID != nil { from = *r.ResubmittedFromID } + headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) + + rawQuery := r.RawQuery + rawQueryCut := r.RawQueryBytes > int64(len(rawQuery)) + + if rawQueryCut { + rawQuery = "" + } + return EventLogView{ - ID: r.ID, - CreatedAt: r.CreatedAt, - Method: r.Method, - ContentType: r.ContentType, - Body: string(body), - BodyBytes: r.BodyBytes, - BodyTruncated: truncated, + ID: r.ID, + Method: r.Method, + ContentType: r.ContentType, + Received: humanize.Time(r.CreatedAt), + ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC", + Body: newBodyView( + "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, + ), + RawQuery: rawQuery, + RawQueryCut: rawQueryCut, + Headers: strings.Join(headers, "\n"), + HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), ResubmittedFromID: from, } } +// requestHeaderLines turns an event's stored request headers, the +// JSON the receiver writes, into one "Name: value" line per value, +// sorted by name. Headers that do not parse, as when the event log +// has cut them, show as none. It reports false, with no lines, when +// the lines, each with the newline that follows it, would hold more +// than maxBytes: a header sent many times is stored with its name +// once but shown with it on every line. +func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) { + var headers http.Header + + if json.Unmarshal([]byte(headersJSON), &headers) != nil { + return nil, true + } + + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + + slices.Sort(names) + + var lines []string + + size := 0 + + for _, name := range names { + for _, value := range headers[name] { + line := name + ": " + value + + size += len(line) + len("\n") + if size > maxBytes { + return nil, false + } + + lines = append(lines, line) + } + } + + return lines, true +} + +// entrypointNames maps each of the webhook's entrypoints to the name +// an event that arrived at it shows: its description, or "Entrypoint" +// when it has none, as the webhook page names it. A deleted +// entrypoint is left out. +func (h *Handlers) entrypointNames( + webhookID string, +) (map[string]string, error) { + var entrypoints []database.Entrypoint + + err := h.db.DB().Where( + "webhook_id = ?", webhookID, + ).Find(&entrypoints).Error + if err != nil { + return nil, err + } + + names := make(map[string]string, len(entrypoints)) + + for i := range entrypoints { + name := entrypoints[i].Description + if name == "" { + name = "Entrypoint" + } + + names[entrypoints[i].ID] = name + } + + return names, nil +} + // trimPartialRune drops a trailing UTF-8 sequence that the // byte-wise cut left incomplete, so a multi-byte rune severed // at the cap does not surface as a mojibake tail. diff --git a/internal/handlers/event_log_view_test.go b/internal/handlers/event_log_view_test.go index e1d5a41..a906d4d 100644 --- a/internal/handlers/event_log_view_test.go +++ b/internal/handlers/event_log_view_test.go @@ -16,7 +16,7 @@ import ( "sneak.berlin/go/webhooker/internal/session" ) -// bodyCap is the number of body bytes the event log page is +// bodyCap is the number of body bytes the lists of events are // allowed to render for one event. const bodyCap = handlers.MaxRenderedBodyBytesForTest @@ -75,9 +75,7 @@ func seedAndProject( wh := seedWebhook(t, db) seedEventWithBody(t, dbMgr, wh.ID, body) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) return views[0] @@ -85,7 +83,7 @@ func seedAndProject( // TestHandleSourceLogs_BoundsOversizeBody proves the rendered // page is bounded by the cap rather than by the stored payload: -// the body here is 64 times the cap, and the ingest path would +// the body here is 16 times the cap, and the ingest path would // accept twice as much again. func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) { t.Parallel() @@ -123,7 +121,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) { // The marker states the true stored size, not the cut one. assert.Contains( t, page, - "showing "+strconv.Itoa(bodyCap)+ + "Showing the first "+strconv.Itoa(bodyCap)+ " of "+strconv.Itoa(storedBytes)+" bytes", ) } @@ -152,34 +150,35 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) { page := renderSourceLogsPage(t, h, sess, wh.ID) assert.Contains(t, page, ""kept"") - assert.NotContains(t, page, "Body truncated for display") + assert.NotContains(t, page, "Showing the first") } // TestEventLogView_CutMidRune proves a multi-byte rune severed // by the byte-wise cut is dropped rather than surfaced as a -// mojibake tail. +// mojibake tail, which would also make the text look binary. func TestEventLogView_CutMidRune(t *testing.T) { t.Parallel() - body := strings.Repeat(snowman, 4096) + body := strings.Repeat(snowman, bodyCap) view := seedAndProject(t, body) // bodyCap bytes hold bodyCap/3 whole snowmen and two bytes // of the next one; those two are dropped. whole := bodyCap / len(snowman) - assert.True(t, view.BodyTruncated) - assert.Equal(t, int64(len(body)), view.BodyBytes) - assert.Equal(t, strings.Repeat(snowman, whole), view.Body) - assert.True(t, utf8.ValidString(view.Body)) - assert.LessOrEqual(t, len(view.Body), bodyCap) + assert.True(t, view.Body.Cut) + assert.False(t, view.Body.Binary) + assert.Equal(t, int64(len(body)), view.Body.Size) + assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text) + assert.True(t, utf8.ValidString(view.Body.Text)) + assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes) + assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap) } -// TestEventLogView_BinaryBodyLeftAsStored proves a binary -// payload is passed through byte for byte. Its tail is invalid -// UTF-8 however the cut falls, so repairing it would misreport -// what the sender delivered. -func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) { +// TestEventLogView_BinaryBodyNotShown proves a body that is not +// text is left out rather than shown as replacement characters, +// whether it is cut or not. +func TestEventLogView_BinaryBodyNotShown(t *testing.T) { t.Parallel() raw := make([]byte, bodyCap+808) @@ -188,12 +187,21 @@ func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) { raw[i] = 0x80 | byte(i%0x40) } - view := seedAndProject(t, string(raw)) + for name, body := range map[string][]byte{ + "cut": raw, + "whole": raw[:2048], + "NUL": []byte("text\x00text"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() - assert.True(t, view.BodyTruncated) - assert.Equal(t, int64(len(raw)), view.BodyBytes) - assert.Equal(t, string(raw[:bodyCap]), view.Body) - assert.False(t, utf8.ValidString(view.Body)) + view := seedAndProject(t, string(body)) + + assert.True(t, view.Body.Binary) + assert.Empty(t, view.Body.Text) + assert.Equal(t, int64(len(body)), view.Body.Size) + }) + } } // TestTrimPartialRune covers the distinction the cut repair diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go new file mode 100644 index 0000000..98740b5 --- /dev/null +++ b/internal/handlers/event_request_test.go @@ -0,0 +1,404 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" +) + +// arrivedAt is how a page names the entrypoint an event arrived at. +func arrivedAt(name string) string { + return `Arrived at ` + name + + `` +} + +// copiedRequestArrivedAt is how a page names, for a resubmitted copy, +// the entrypoint the request it copies arrived at. +func copiedRequestArrivedAt(name string) string { + return `The request it copies arrived at ` + + `` + name + `` +} + +// headerBox is how a page shows an event's request header lines: as +// one block of text in a single box. +func headerBox(lines ...string) string { + return `
` + strings.Join(lines, "\n") + `
` +} + +// showHeadersLink is the event log's link to an event's own page for +// request headers it leaves out. +func showHeadersLink(webhookID, eventID string) string { + return `Show the request headers` +} + +// entrypoint records one of the fixture webhook's entrypoints. +func (f *recentEventsFixture) entrypoint( + t *testing.T, description string, +) *database.Entrypoint { + t.Helper() + + ep := &database.Entrypoint{ + WebhookID: f.webhook.ID, + Path: uuid.NewString(), + Description: description, + Active: true, + } + + require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error) + + return ep +} + +// eventAt records an event that arrived at the entrypoint with the +// given request headers, stored as JSON as the receiver stores them. +func (f *recentEventsFixture) eventAt( + t *testing.T, + ep *database.Entrypoint, + headersJSON string, + receivedAt time.Time, +) *database.Event { + t.Helper() + + event := &database.Event{ + WebhookID: f.webhook.ID, + EntrypointID: ep.ID, + Method: http.MethodPost, + Headers: headersJSON, + Body: "{}", + BodyBytes: 2, + ContentType: contentTypeJSON, + } + event.CreatedAt = receivedAt + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(event).Error) + + return event +} + +// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two +// events that arrived at two entrypoints each show their own +// entrypoint and request headers, in the event log and on their own +// pages, with the headers sorted by name, escaped and keeping their +// whitespace, and never the entrypoint's URL. +func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders( + t *testing.T, +) { + t.Parallel() + + f := newRecentEventsFixture(t) + billing := f.entrypoint(t, "Billing sender") + unnamed := f.entrypoint(t, "") + + // Stored in reverse name order. + older := f.eventAt(t, billing, + `{"X-Shop-Event":["order.created"],`+ + `"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`, + time.Now().Add(-time.Minute)) + newer := f.eventAt(t, unnamed, + `{"X-Shop-Event":["order.paid"],"X-Note":["hi"]}`, + time.Now()) + + olderShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Billing sender")) + assert.Contains(t, page, "No query string.") + assert.Contains(t, page, headerBox( + "Accept: */*", + "User-Agent: shop/1 build\t7", + "X-Shop-Event: order.created", + ), "headers are sorted by name") + assert.NotContains(t, page, "order.paid") + assert.NotContains(t, page, billing.Path) + } + + newerShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Entrypoint")) + assert.Contains(t, page, headerBox( + "X-Note: <b>hi</b>", + "X-Shop-Event: order.paid", + )) + assert.NotContains(t, page, "hi") + assert.NotContains(t, page, "order.created") + assert.NotContains(t, page, unnamed.Path) + } + + // The log lists the newer event first, so everything between + // the two events' first mentions belongs to the newer one. + _, rest, found := strings.Cut(renderSourceLogsPage( + t, f.h, f.sess, f.webhook.ID, + ), newer.ID) + require.True(t, found) + + newerPart, olderPart, found := strings.Cut(rest, older.ID) + require.True(t, found) + + newerShows(t, newerPart) + olderShows(t, olderPart) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID) + require.Equal(t, http.StatusOK, w.Code) + newerShows(t, w.Body.String()) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID) + require.Equal(t, http.StatusOK, w.Code) + olderShows(t, w.Body.String()) +} + +// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint +// has since been deleted says so in the event log and on its own page. +func TestEventRequest_DeletedEntrypoint(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Retired sender") + event := f.eventAt(t, ep, `{}`, time.Now()) + + require.NoError(t, f.db.DB().Delete(ep).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, arrivedAt("deleted entrypoint")) + assert.NotContains(t, page, "Retired sender") + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint")) + assert.NotContains(t, w.Body.String(), "Retired sender") +} + +// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy +// of that copy each say the request they copy arrived at the +// entrypoint, in the event log and on their own pages, and never that +// they did. +func TestEventRequest_ResubmittedCopy(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute)) + copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute)) + copyOfCopy := f.eventAt(t, ep, `{}`, time.Now()) + + require.NoError(t, f.webhookDB.Model(copied).Update( + "resubmitted_from_id", original.ID, + ).Error) + require.NoError(t, f.webhookDB.Model(copyOfCopy).Update( + "resubmitted_from_id", copied.ID, + ).Error) + + // The log lists the newest event first, and each event's Resubmit + // form comes before its entrypoint, so cutting the page at the + // copy's and the original's forms leaves each event's entrypoint + // in its own part. + copyOfCopyPart, rest, found := strings.Cut( + renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID), + "/events/"+copied.ID+"/resubmit", + ) + require.True(t, found) + + copyPart, originalPart, found := strings.Cut( + rest, "/events/"+original.ID+"/resubmit", + ) + require.True(t, found) + + for _, part := range []string{copyOfCopyPart, copyPart} { + assert.Contains(t, part, copiedRequestArrivedAt("Billing sender")) + assert.NotContains(t, part, arrivedAt("Billing sender")) + } + + assert.Contains(t, originalPart, arrivedAt("Billing sender")) + assert.NotContains(t, originalPart, + copiedRequestArrivedAt("Billing sender")) + + for _, event := range []*database.Event{copied, copyOfCopy} { + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), + copiedRequestArrivedAt("Billing sender")) + assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender")) + } +} + +// TestEventRequest_HeadersOverTheLimit proves the event log leaves out +// request headers that hold more than it shows of a body, whether +// stored or as lines, and links to the event's own page, which shows +// them all. +func TestEventRequest_HeadersOverTheLimit(t *testing.T) { + t.Parallel() + + // The receiver stores each "<" as six bytes of JSON, so this + // header is over the limit stored but not as a line. + const lessThans = bodyCap/6 + 1 + + // A header sent many times is stored with its name once, and + // shown with it on every line. + repeatedName := "X-Repeated-" + strings.Repeat("r", 1000) + + tests := map[string]struct { + headers http.Header + line string + }{ + "stored": { + headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}}, + line: "X-Long: " + strings.Repeat("<", lessThans), + }, + "as lines": { + headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)}, + line: repeatedName + ": ", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + headersJSON, err := json.Marshal(tc.headers) + require.NoError(t, err) + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, string(headersJSON), time.Now()) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID)) + assert.NotContains(t, page, tc.line) + assert.Less(t, len(page), 4*bodyCap) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), tc.line) + assert.NotContains(t, w.Body.String(), "Show the request headers") + }) + } +} + +// TestEventRequest_ManyShortHeaderLines proves that for many short +// request header lines the event log writes no more than its limit, +// apart from escaping: lines that fill the limit show as one block of +// text, and one line more is left out with a link to the event's own +// page. +func TestEventRequest_ManyShortHeaderLines(t *testing.T) { + t.Parallel() + + // Each "A: " line and the newline after it hold four bytes, so + // this many lines fill the limit exactly. Each line in its own + // element would make the page many times the limit. + const fill = bodyCap / len("A: \n") + + tests := map[string]struct { + lines int + shown bool + }{ + "filling the limit": {lines: fill, shown: true}, + "one over the limit": {lines: fill + 1, shown: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + headersJSON, err := json.Marshal(http.Header{ + "A": slices.Repeat([]string{""}, tc.lines), + }) + require.NoError(t, err) + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, string(headersJSON), time.Now()) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...) + link := showHeadersLink(f.webhook.ID, event.ID) + + assert.Equal(t, tc.shown, strings.Contains(page, box)) + assert.Equal(t, !tc.shown, strings.Contains(page, link)) + assert.Less(t, len(page), 4*bodyCap) + }) + } +} + +// TestHandleWebhook_StoresAndShowsTheQueryString posts to an +// entrypoint's URL with a query string and proves the event stores it +// as sent, and shows it escaped in the event log and on its own page, +// in a box like the one the request headers show in. +func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := seedEntrypoint(t, f.db, f.webhook.ID) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"), + ) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", ep.Path) + + req = req.WithContext(context.WithValue( + req.Context(), chi.RouteCtxKey, rctx, + )) + + w := httptest.NewRecorder() + f.h.HandleWebhook().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var stored database.Event + + require.NoError(t, f.webhookDB.First(&stored).Error) + assert.Equal(t, "a=1&b=2", stored.RawQuery) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, headerBox("a=1&b=2")) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox("a=1&b=2")) +} + +// TestEventRequest_QueryStringOverTheLimit proves the event log leaves +// out a query string that holds more than it shows of a body, and links +// to the event's own page, which shows it whole. +func TestEventRequest_QueryStringOverTheLimit(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, `{}`, time.Now()) + query := "q=" + strings.Repeat("x", bodyCap) + + require.NoError(t, f.webhookDB.Model(event).Update( + "raw_query", query, + ).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, `Show the query string`) + assert.NotContains(t, page, "q=x") + assert.Less(t, len(page), 4*bodyCap) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox(query)) + assert.NotContains(t, w.Body.String(), "Show the query string") +} diff --git a/internal/handlers/event_resubmit.go b/internal/handlers/event_resubmit.go index 21e54b8..211ac00 100644 --- a/internal/handlers/event_resubmit.go +++ b/internal/handlers/event_resubmit.go @@ -3,7 +3,6 @@ package handlers import ( "errors" "net/http" - "strconv" "github.com/go-chi/chi" "github.com/google/uuid" @@ -11,43 +10,19 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// resubmitOutcomeParam is the query parameter the resubmit POST -// redirects with and the event log page reads its banner from. -const resubmitOutcomeParam = "resubmit" - -// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect -// carries one of these fixed codes rather than a message, so nothing a -// client submits can reach the rendered page through it. -type resubmitOutcomeCode string - +// The outcomes of a resubmit POST, as the notice codes its redirect +// carries. noticeFor holds the line each one shows. const ( // resubmitQueued reports that a new event was stored and its // deliveries handed to the delivery engine. - resubmitQueued resubmitOutcomeCode = "queued" + resubmitQueued noticeCode = "resubmit-queued" // resubmitNoTargets reports a source with no active targets. The // new event is stored either way, exactly as a received event // with no targets is. - resubmitNoTargets resubmitOutcomeCode = "no-targets" + resubmitNoTargets noticeCode = "resubmit-no-targets" ) -// resubmitOutcome returns the banner the event log page shows for an -// outcome code, and whether the resubmit was queued. An unrecognised -// code yields no banner. -func resubmitOutcome(code string) (string, bool) { - switch resubmitOutcomeCode(code) { - case resubmitQueued: - return "Resubmitted: a new event was created from the stored " + - "one and queued to every active target.", true - case resubmitNoTargets: - return "Resubmitted: a new event was created, but this " + - "source has no active targets, so nothing was queued.", - true - default: - return "", false - } -} - // resubmitSource is the stored event a resubmit copies. Its body is // read as bytes rather than as a string so the copy is byte-identical // to what was received, whatever the payload's encoding. @@ -55,6 +30,7 @@ type resubmitSource struct { ID string EntrypointID string Method string + RawQuery string Headers string ContentType string Body []byte @@ -64,7 +40,7 @@ type resubmitSource struct { // The cast to blob is what makes the driver hand back the stored bytes // rather than a string conversion, the same reason eventBodyQuery // casts. -const resubmitColumns = "id, entrypoint_id, method, headers, " + +const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " + "content_type, cast(body as blob) AS body" // HandleEventResubmit re-injects a stored event as a new undelivered @@ -99,7 +75,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc { // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) + h.renderError(w, r, http.StatusBadRequest) return } @@ -120,20 +96,20 @@ func (h *Handlers) resubmitEvent( // alphabet rather than from the request. eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) if err != nil { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } if !h.dbMgr.DBExists(webhook.ID) { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { - h.serverError(w, "failed to get webhook database", err) + h.serverError(w, r, "failed to get webhook database", err) return } @@ -147,7 +123,7 @@ func (h *Handlers) resubmitEvent( webhookDB, webhook.ID, eventID.String(), ) if err != nil { - h.serverError(w, "failed to load event to resubmit", err) + h.serverError(w, r, "failed to load event to resubmit", err) return } @@ -155,7 +131,7 @@ func (h *Handlers) resubmitEvent( // A miss is a 404 whether the event was reaped, belongs to // another webhook, or never existed. if !found { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return } @@ -170,8 +146,9 @@ func (h *Handlers) resubmitEvent( // per-webhook database files — a sibling webhook's event is not in the // database being queried at all — and is there so the scoping survives // any future change that puts more than one webhook's events in one -// file. Going through Model applies GORM's soft-delete scope, which is -// what stops a reaped event being resubmitted. +// file. A reaped event is not found because the retention reaper +// deletes its row outright rather than marking it deleted; see +// deleteEvents in internal/database/retention.go. func loadResubmitSource( webhookDB *gorm.DB, webhookID, eventID string, @@ -207,7 +184,7 @@ func (h *Handlers) queueResubmit( // inactive one is skipped rather than refused. targets, err := h.loadActiveTargets(webhook.ID) if err != nil { - h.serverError(w, "failed to query targets", err) + h.serverError(w, r, "failed to query targets", err) return } @@ -217,6 +194,7 @@ func (h *Handlers) queueResubmit( WebhookID: webhook.ID, EntrypointID: src.EntrypointID, Method: src.Method, + RawQuery: src.RawQuery, HeadersJSON: src.Headers, ContentType: src.ContentType, Body: src.Body, @@ -225,7 +203,7 @@ func (h *Handlers) queueResubmit( targets, ) if err != nil { - h.serverError(w, "failed to store resubmitted event", err) + h.serverError(w, r, "failed to store resubmitted event", err) return } @@ -245,29 +223,5 @@ func (h *Handlers) queueResubmit( code = resubmitNoTargets } - h.finishResubmit(w, r, webhook, code) -} - -// finishResubmit redirects back to the event log the resubmit was -// triggered from, carrying the outcome code the page turns into a -// banner and the page number the form submitted. -func (h *Handlers) finishResubmit( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, - code resubmitOutcomeCode, -) { - dest := "/source/" + webhook.ID + "/logs?" + - resubmitOutcomeParam + "=" + string(code) - - // The page is read from the form rather than the query string: - // this is a POST, and its query string is what logs and Referer - // headers record. - if page := pageOrFirst( - r.PostFormValue("page"), - ); page > 1 { - dest += "&page=" + strconv.Itoa(page) - } - - http.Redirect(w, r, dest, http.StatusSeeOther) + redirectToEventLog(w, r, webhook, code) } diff --git a/internal/handlers/event_resubmit_test.go b/internal/handlers/event_resubmit_test.go index b2ea384..7ba344e 100644 --- a/internal/handlers/event_resubmit_test.go +++ b/internal/handlers/event_resubmit_test.go @@ -22,9 +22,13 @@ import ( // dispatches to it: the notifier is recorded, not run. const resubmitTargetURL = "http://93.184.216.34/hook" -// resubmitEventHeaders is the stored header JSON a seeded event -// carries, so a test can prove the copy takes it verbatim. -const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` +// resubmitEventHeaders and resubmitEventQuery are the stored header +// JSON and query string a seeded event carries, so a test can prove the +// copy takes them verbatim. +const ( + resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` + resubmitEventQuery = "a=1&b=2" +) // seedStoredEvent records one event in a webhook's own database with // no deliveries at all, which is the state a captured event is in when @@ -43,6 +47,7 @@ func seedStoredEvent( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: resubmitEventQuery, Headers: resubmitEventHeaders, Body: body, ContentType: contentTypeJSON, @@ -65,7 +70,7 @@ func postResubmit( t.Helper() req := postRequest( - "/source/"+webhookID+"/events/"+eventID+"/resubmit", + "/hook/"+webhookID+"/events/"+eventID+"/resubmit", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ), @@ -154,7 +159,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?notice=resubmit-queued", w.Header().Get("Location"), ) @@ -202,8 +207,10 @@ func assertEventCopy( t.Helper() assert.Equal(t, original.Method, fresh.Method) + assert.Equal(t, resubmitEventQuery, fresh.RawQuery) assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Body, fresh.Body) + assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes) assert.Equal(t, original.ContentType, fresh.ContentType) assert.Equal(t, original.EntrypointID, fresh.EntrypointID) assert.Equal(t, original.WebhookID, fresh.WebhookID) @@ -235,6 +242,7 @@ func assertResubmitTask( assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, fresh.Method, task.Method) + assert.Equal(t, fresh.RawQuery, task.RawQuery) assert.Equal(t, fresh.Headers, task.Headers) assert.Equal(t, fresh.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) @@ -281,7 +289,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?notice=resubmit-queued", w.Header().Get("Location"), "a resubmit must not be refused while an earlier "+ "one is in flight", @@ -435,7 +443,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?notice=resubmit-queued", w.Header().Get("Location"), "an inactive target is skipped, not an error", ) @@ -481,7 +489,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=no-targets", + "/hook/"+wh.ID+"/events?notice=resubmit-no-targets", w.Header().Get("Location"), ) @@ -597,7 +605,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) { ) assert.Contains( t, body, - "/source/"+wh.ID+"/events/"+original.ID+"/resubmit", + "/hook/"+wh.ID+"/events/"+original.ID+"/resubmit", "the log must offer the resubmit action per event", ) } diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index 4612e94..852f79a 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -1,10 +1,14 @@ package handlers import ( + "context" "html/template" "log/slog" "net/http" + "net/http/httptest" + "time" + "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) @@ -15,10 +19,16 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) { s.log = log } -// MaxRenderedBodyBytesForTest exposes the event log's body cap -// to the handlers_test package. +// MaxRenderedBodyBytesForTest exposes the body cap of the lists +// of events to the handlers_test package. const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes +// NewBodyViewForTest exposes newBodyView for use in the +// handlers_test package. +func NewBodyViewForTest(body []byte, size int64) BodyView { + return newBodyView("/hook/w/events/e", body, size) +} + // MaxRenderedResponseBytesForTest exposes the event log's // delivery response cap to the handlers_test package. const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes @@ -32,11 +42,14 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts // the handlers enforce rather than a number copied beside it. const MaxTargetRetriesForTest = maxTargetRetries -// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test -// package. -func PageOrFirstForTest(s string) int { - return pageOrFirst(s) -} +// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two +// messages the webhook delete handler logs when a file of the event +// database is left on disk, so a test checking that one is absent +// checks for the handler's own wording. +const ( + EventDBLeftMsgForTest = eventDBLeftMsg + SidecarLeftMsgForTest = sidecarLeftMsg +) // DummyVerificationsForTest reports how many equivalent-cost // verifications were charged for usernames that do not exist. It @@ -60,15 +73,45 @@ func TrimPartialRuneForTest(b []byte) []byte { func (s *Handlers) LoadEventLogViewsForTest( w http.ResponseWriter, webhook database.Webhook, - page int, ) []EventLogView { views, _, _ := s.loadEventsWithDeliveries( - w, webhook, nil, page, + w, newRequestForTest(), webhook, nil, nil, ) return views } +// WebhookStatsForTest returns the figures the statistics pane on a +// webhook's page shows, from the webhook's entrypoints and targets +// loaded as that page loads them. +func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats { + var entrypoints []database.Entrypoint + + s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints) + + var targets []database.Target + + s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets) + + return s.loadWebhookStats(webhookID, entrypoints, targets) +} + +// FinishedByTargetForTest exposes finishedByTarget for use in the +// handlers_test package. +func FinishedByTargetForTest( + webhookDB *gorm.DB, since time.Time, +) ([]TargetFinished, error) { + return finishedByTarget(webhookDB, since) +} + +// newRequestForTest is the request the helpers here pass on for +// callers that have none: it is used only to render the error page. +func newRequestForTest() *http.Request { + return httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/", nil, + ) +} + // AddTemplateForTest registers a template under a page name so that // the handlers_test package can drive the render path with a // template of its own. @@ -93,22 +136,20 @@ func (s *Handlers) RenderTemplateForTest( // BuildSlackTargetConfigForTest exposes // buildSlackTargetConfig for use in the handlers_test package. func (s *Handlers) BuildSlackTargetConfigForTest( - w http.ResponseWriter, - r *http.Request, + ctx context.Context, targetURL string, -) (string, error) { - return s.buildSlackTargetConfig(w, r, targetURL) +) (string, string, error) { + return s.buildSlackTargetConfig(ctx, targetURL) } // BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig // for use in the handlers_test package, taking the form fields // an HTTP target's configuration is built from. func (s *Handlers) BuildHTTPTargetConfigForTest( - w http.ResponseWriter, - r *http.Request, + ctx context.Context, targetURL, headers, timeout string, -) (string, error) { - return s.buildHTTPTargetConfig(w, r, targetFormInput{ +) (string, string, error) { + return s.buildHTTPTargetConfig(ctx, targetFormInput{ URL: targetURL, Headers: headers, Timeout: timeout, @@ -118,9 +159,17 @@ func (s *Handlers) BuildHTTPTargetConfigForTest( // BuildDatabaseTargetConfigForTest exposes // buildDatabaseTargetConfig for use in the handlers_test // package. -func (s *Handlers) BuildDatabaseTargetConfigForTest( - w http.ResponseWriter, - expiry string, -) (string, error) { - return s.buildDatabaseTargetConfig(w, expiry) +func BuildDatabaseTargetConfigForTest( + expiry, rotation string, +) (string, string, error) { + return buildDatabaseTargetConfig(expiry, rotation) +} + +// ArchiveFileViewForTest exposes archiveFileView, which describes a +// database target's archive files as the target list shows them at +// now. +func (s *Handlers) ArchiveFileViewForTest( + webhook *database.Webhook, target *database.Target, now time.Time, +) *ArchiveFileView { + return s.archiveFileView(webhook, target, now) } diff --git a/internal/handlers/gormlogbound_test.go b/internal/handlers/gormlogbound_test.go index 123eb4d..d5458dc 100644 --- a/internal/handlers/gormlogbound_test.go +++ b/internal/handlers/gormlogbound_test.go @@ -306,7 +306,7 @@ func postWebhook( t.Helper() req := httptest.NewRequestWithContext( - context.Background(), http.MethodPost, "/webhook/x", + context.Background(), http.MethodPost, "/h/x", strings.NewReader("{}"), ) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 349771e..2b689e6 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,9 +10,12 @@ import ( "html/template" "log/slog" "net/http" + "sync" "sync/atomic" + "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" + "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" @@ -27,15 +30,17 @@ import ( const ( // maxBodyShift is the bit shift for 1 MB body limit. maxBodyShift = 20 - // recentEventLimit is the number of recent events to show. - recentEventLimit = 20 - // paginationPerPage is the number of items per page. - paginationPerPage = 25 + // recentEventLimit is the number of most recent events that a + // webhook's page and its event log show. + recentEventLimit = 50 // tmplKeyError is the template data key for an error message. tmplKeyError = "Error" // tmplKeyWebhook is the template data key for a webhook. tmplKeyWebhook = "Webhook" + // tmplKeyNext is the template data key for the page to return + // to after login. + tmplKeyNext = "Next" ) // errInvalidPassword is returned when a password does not match. @@ -51,16 +56,20 @@ var errVerificationBusy = errors.New( type HandlersParams struct { fx.In - Logger *logger.Logger - Globals *globals.Globals - Database *database.Database - WebhookDBMgr *database.WebhookDBManager - Healthcheck *healthcheck.Healthcheck - Session *session.Session - Middleware *middleware.Middleware - Notifier delivery.Notifier - Evictor delivery.WebhookEvictor - SSRFGuard *delivery.Guard + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config + Database *database.Database + WebhookDBMgr *database.WebhookDBManager + Healthcheck *healthcheck.Healthcheck + Session *session.Session + Middleware *middleware.Middleware + Notifier delivery.Notifier + Archives delivery.Archives + CircuitBreakers delivery.CircuitBreakers + SSRFGuard *delivery.Guard + Metrics *metrics.Set + Registry *prometheus.Registry } // Handlers provides HTTP handler methods for all application @@ -74,7 +83,8 @@ type Handlers struct { session *session.Session mw *middleware.Middleware notifier delivery.Notifier - evictor delivery.WebhookEvictor + archives delivery.Archives + breakers delivery.CircuitBreakers mtr *metrics.Set templates map[string]*template.Template @@ -83,6 +93,17 @@ type Handlers struct { // is one delivery will actually attempt. ssrf *delivery.Guard + // renameMu makes the webhook edit, the target edit and target + // creation run one at a time, each held from loading the stored + // names through the archive rename, the save and any move back. + // Interleaved, one could rename an archive between another's + // rename and save, leaving the file named for one edit and the + // stored names from the other. An archive download holds it while + // it reads the stored names and lists the files they give, and + // again for each file while it finds the file under the names + // stored then and opens it. + renameMu sync.Mutex + // dummyVerifications counts the equivalent-cost verifications // charged for usernames that do not exist. It exists so a test // can prove that path runs without measuring wall-clock time. @@ -91,18 +112,26 @@ type Handlers struct { // parsePageTemplate parses a page-specific template set from the // embedded FS. Each page template is combined with the shared -// base, htmlheader, and navbar templates. The page file must be -// listed first so that its root action ({{template "base" .}}) -// becomes the template set's entry point. -func parsePageTemplate(pageFile string) *template.Template { +// base, htmlheader, navbar and notice templates, and with any further +// files the page includes. The set is named after the page file, so +// the page's root action ({{template "base" .}}) is its entry point. +// +// The page file is parsed last because a later definition of a name +// replaces an earlier one: the page's {{define "title"}} must replace +// the {{block "title"}} fallback in htmlheader.html. +func parsePageTemplate( + pageFile string, included ...string, +) *template.Template { + files := append([]string{ + "base.html", + "htmlheader.html", + "navbar.html", + "notice.html", + }, included...) + files = append(files, pageFile) + return template.Must( - template.ParseFS( - templates.Templates, - pageFile, - "base.html", - "htmlheader.html", - "navbar.html", - ), + template.New(pageFile).ParseFS(templates.Templates, files...), ) } @@ -121,20 +150,32 @@ func New( s.session = params.Session s.mw = params.Middleware s.notifier = params.Notifier - s.evictor = params.Evictor - s.mtr = metrics.Default() + s.archives = params.Archives + s.breakers = params.CircuitBreakers + s.mtr = params.Metrics s.ssrf = params.SSRFGuard // Parse all page templates once at startup s.templates = map[string]*template.Template{ - "login.html": parsePageTemplate("login.html"), - "profile.html": parsePageTemplate("profile.html"), - "sources_list.html": parsePageTemplate("sources_list.html"), - "sources_new.html": parsePageTemplate("sources_new.html"), - "source_detail.html": parsePageTemplate("source_detail.html"), - "source_edit.html": parsePageTemplate("source_edit.html"), - "source_logs.html": parsePageTemplate("source_logs.html"), - "target_edit.html": parsePageTemplate("target_edit.html"), + "login.html": parsePageTemplate("login.html"), + "profile.html": parsePageTemplate("profile.html"), + "settings.html": parsePageTemplate("settings.html"), + "sources_list.html": parsePageTemplate("sources_list.html"), + "sources_new.html": parsePageTemplate("sources_new.html"), + "source_detail.html": parsePageTemplate( + "source_detail.html", "webhook_stats.html", "event_body.html", + ), + "source_edit.html": parsePageTemplate("source_edit.html"), + "source_logs.html": parsePageTemplate( + "source_logs.html", "event_request.html", "event_body.html", + "delivery_row.html", "delivery_attempts.html", + ), + "event_detail.html": parsePageTemplate( + "event_detail.html", "event_request.html", "event_body.html", + "delivery_row.html", "delivery_attempts.html", + ), + "target_edit.html": parsePageTemplate("target_edit.html"), + "error.html": parsePageTemplate("error.html"), } lc.Append(fx.Hook{ @@ -146,6 +187,16 @@ func New( return s, nil } +// HandleErrorPage returns a handler that answers every request with +// the error page for status. The router uses it for unknown paths, the +// CSRF middleware for a refused form, and each admin page route +// group's recoverer for a panic. +func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.renderError(w, r, status) + } +} + func (s *Handlers) respondJSON( w http.ResponseWriter, _ *http.Request, @@ -163,15 +214,78 @@ func (s *Handlers) respondJSON( } } -// serverError logs an error and sends a 500 response. +// serverError logs an error and answers with the 500 error page. func (s *Handlers) serverError( - w http.ResponseWriter, msg string, err error, + w http.ResponseWriter, r *http.Request, msg string, err error, ) { s.log.Error(msg, "error", err) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + s.renderError(w, r, http.StatusInternalServerError) +} + +// renderError answers with status and the error page: the normal +// layout, one fixed line explaining the status, and a link back to the +// webhook list, or to sign-in when nobody is signed in. +// +// It renders the page itself rather than through renderTemplate, +// whose own failure comes here. If the error page cannot render +// either, the answer is the same status in plain text: never a second +// attempt, and never a different status. +func (s *Handlers) renderError( + w http.ResponseWriter, + r *http.Request, + status int, +) { + // The page names the signed-in user, and some error pages are + // served outside the routes where NoCache runs. + w.Header().Set("Cache-Control", "no-store") + + // No notice: one would say an action worked above a page saying + // the request failed. + data := s.pageData(r, map[string]any{ + "Status": status, + "StatusText": http.StatusText(status), + "Message": errorPageText(status), + }, nil) + + var buf bytes.Buffer + + err := s.templates["error.html"].Execute(&buf, data) + if err != nil { + s.log.Error("failed to render error page", "error", err) + http.Error(w, http.StatusText(status), status) + + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + + _, err = buf.WriteTo(w) + if err != nil { + s.log.Error("failed to write error page", "error", err) + } +} + +// errorPageText is the line the error page shows for status. It is +// fixed per status, so the page tells the reader no more than the +// plain-text answers it replaced did. +func errorPageText(status int) string { + switch status { + case http.StatusBadRequest: + return "The request could not be read." + case http.StatusForbidden: + return "The request was refused. If it came from a form " + + "left open for a long time, reload the page and try " + + "again." + case http.StatusNotFound: + return "There is nothing here. It may have been deleted, " + + "or the address may be wrong." + case http.StatusServiceUnavailable: + return "The server is busy. Please try again in a moment." + default: // http.StatusInternalServerError + return "Something went wrong on the server. Please try " + + "again." + } } // UserInfo represents user information for templates @@ -185,6 +299,7 @@ type templateDataWrapper struct { User *UserInfo CSRFToken string Version string + Notice *notice Data any } @@ -211,12 +326,26 @@ func (s *Handlers) getUserInfo( } // renderTemplate renders a pre-parsed template with common -// data +// data and answers 200. func (s *Handlers) renderTemplate( w http.ResponseWriter, r *http.Request, pageTemplate string, data any, +) { + s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK) +} + +// renderTemplateStatus is renderTemplate answering with status, for a +// form shown again with an error. Call it instead of WriteHeader +// followed by renderTemplate: the status is written only once the page +// has rendered, so a failed render can still answer 500. +func (s *Handlers) renderTemplateStatus( + w http.ResponseWriter, + r *http.Request, + pageTemplate string, + data any, + status int, ) { tmpl, ok := s.templates[pageTemplate] if !ok { @@ -224,14 +353,22 @@ func (s *Handlers) renderTemplate( "template not found", "template", pageTemplate, ) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + s.renderError(w, r, http.StatusInternalServerError) return } + s.executeTemplate( + w, r, tmpl, s.pageData(r, data, noticeFor(r)), status, + ) +} + +// pageData adds the fields the shared layout renders to a page's own +// data. The layout shows the notice, when there is one, above the +// page. +func (s *Handlers) pageData( + r *http.Request, data any, pageNotice *notice, +) any { userInfo := s.getUserInfo(r) csrfToken := middleware.CSRFToken(r) @@ -245,33 +382,34 @@ func (s *Handlers) renderTemplate( m["User"] = userInfo m["CSRFToken"] = csrfToken m["Version"] = version - s.executeTemplate(w, tmpl, m) + m["Notice"] = pageNotice - return + return m } - wrapper := templateDataWrapper{ + return templateDataWrapper{ User: userInfo, CSRFToken: csrfToken, Version: version, + Notice: pageNotice, Data: data, } - - s.executeTemplate(w, tmpl, wrapper) } -// executeTemplate renders the template into a buffer and writes to -// the response only once rendering has fully succeeded. Executing -// straight into the ResponseWriter commits a partial body and a 200 -// status before a mid-render error can be reported, leaving no way -// to serve a 500. Buffering makes a page's rendered size resident -// memory per concurrent viewer, so every page owes it a bound: the -// event log caps each stored body at maxRenderedBodyBytes for exactly -// this reason. +// executeTemplate renders the template into a buffer and writes status +// and the page to the response only once rendering has fully +// succeeded. Executing straight into the ResponseWriter commits a +// partial body and the status before a mid-render error can be +// reported, leaving no way to serve a 500. Buffering makes a page's +// rendered size resident memory per concurrent viewer, so every page +// owes it a bound: the lists of events cap each stored body at +// maxRenderedBodyBytes for exactly this reason. func (s *Handlers) executeTemplate( w http.ResponseWriter, + r *http.Request, tmpl *template.Template, data any, + status int, ) { var buf bytes.Buffer @@ -280,15 +418,13 @@ func (s *Handlers) executeTemplate( s.log.Error( "failed to execute template", "error", err, ) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + s.renderError(w, r, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) _, err = buf.WriteTo(w) if err != nil { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 3e9874a..2cbeaf1 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -3,11 +3,14 @@ package handlers_test import ( "context" "errors" + "fmt" "html/template" + "log/slog" "net/http" "net/http/httptest" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,6 +23,7 @@ import ( "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/session" ) @@ -51,23 +55,103 @@ func (n *recordingNotifier) Tasks() []delivery.Task { return out } -// recordingEvictor is a delivery.WebhookEvictor that records -// the webhook ids it was asked to evict, so a test can prove -// that a deletion path reached the delivery engine. -type recordingEvictor struct { - mu sync.Mutex - evicted []string +// recordingArchives is a delivery.Archives that records what it +// was asked to do, so a test can prove that a deletion or rename +// path reached the delivery engine. After FailRenames, every +// rename of that target fails with the given error. After +// BlockNextRename, the next rename is recorded and then waits. +type recordingArchives struct { + mu sync.Mutex + evicted []string + evictedTargets []string + renames []archiveRename + renameErrs map[string]error + entered chan struct{} + release chan struct{} } -func (r *recordingEvictor) EvictWebhook(webhookID string) { +// errInjectedRename is the failure a test hands FailRenames. +var errInjectedRename = errors.New("injected rename failure") + +// errNameTaken is what the delivery engine returns when a file +// already has an archive's new name, here archive-taken.db. +var errNameTaken = fmt.Errorf( + "%w: archive-taken.db", delivery.ErrArchiveNameTaken, +) + +// archiveRename is one recorded Rename call. +type archiveRename struct { + TargetID string + WebhookName string + TargetName string +} + +func (r *recordingArchives) EvictWebhook(webhookID string) { r.mu.Lock() defer r.mu.Unlock() r.evicted = append(r.evicted, webhookID) } +func (r *recordingArchives) EvictTarget(targetID string) { + r.mu.Lock() + defer r.mu.Unlock() + + r.evictedTargets = append(r.evictedTargets, targetID) +} + +func (r *recordingArchives) Rename( + targetID, webhookName, targetName string, +) error { + r.mu.Lock() + + r.renames = append(r.renames, archiveRename{ + TargetID: targetID, + WebhookName: webhookName, + TargetName: targetName, + }) + err := r.renameErrs[targetID] + entered, release := r.entered, r.release + r.entered, r.release = nil, nil + + r.mu.Unlock() + + if entered != nil { + close(entered) + <-release + } + + return err +} + +// BlockNextRename makes the next rename, once recorded, wait until +// the returned release is called. The returned channel is closed +// when that rename starts waiting. +func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) { + entered := make(chan struct{}) + release := make(chan struct{}) + + r.mu.Lock() + r.entered, r.release = entered, release + r.mu.Unlock() + + return entered, func() { close(release) } +} + +// FailRenames makes every later rename of targetID fail with err. +func (r *recordingArchives) FailRenames(targetID string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.renameErrs == nil { + r.renameErrs = map[string]error{} + } + + r.renameErrs[targetID] = err +} + // Evicted returns a copy of the recorded webhook ids. -func (r *recordingEvictor) Evicted() []string { +func (r *recordingArchives) Evicted() []string { r.mu.Lock() defer r.mu.Unlock() @@ -77,22 +161,97 @@ func (r *recordingEvictor) Evicted() []string { return out } +// EvictedTargets returns a copy of the recorded target ids. +func (r *recordingArchives) EvictedTargets() []string { + r.mu.Lock() + defer r.mu.Unlock() + + out := make([]string, len(r.evictedTargets)) + copy(out, r.evictedTargets) + + return out +} + +// Renames returns a copy of the recorded renames. +func (r *recordingArchives) Renames() []archiveRename { + r.mu.Lock() + defer r.mu.Unlock() + + out := make([]archiveRename, len(r.renames)) + copy(out, r.renames) + + return out +} + +// testCircuitBreakers is a delivery.CircuitBreakers that reports, for +// each target, the circuit state and cooldown a test gave it with Set, +// and a closed breaker for any other target. +type testCircuitBreakers struct { + mu sync.Mutex + states map[string]delivery.CircuitState + cooldowns map[string]time.Duration +} + +// Set makes the target's breaker read as state, with cooldown left. +func (b *testCircuitBreakers) Set( + targetID string, state delivery.CircuitState, cooldown time.Duration, +) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.states == nil { + b.states = map[string]delivery.CircuitState{} + b.cooldowns = map[string]time.Duration{} + } + + b.states[targetID] = state + b.cooldowns[targetID] = cooldown +} + +func (b *testCircuitBreakers) StateAndCooldown( + targetID string, +) (delivery.CircuitState, time.Duration) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.states[targetID], b.cooldowns[targetID] +} + +// newTestApp returns an app whose RequireStart fails the test when +// starting takes longer than fx's default start timeout of 15s. That +// limit catches a start that hangs, not a busy host: measured with make +// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this +// package's starts took 0.49s. func newTestApp( t *testing.T, targets ...any, ) *fxtest.App { t.Helper() + return newTestAppWithConfig( + t, &config.Config{DataDir: t.TempDir()}, targets..., + ) +} + +// newTestAppWithConfig is newTestApp over a caller-supplied Config. +func newTestAppWithConfig( + t *testing.T, + cfg *config.Config, + targets ...any, +) *fxtest.App { + t.Helper() + return fxtest.New( t, + // fx's own log is discarded, not sent to t.Logf: a hook still + // running after a start or stop timeout would write there after + // the test has returned. + fx.NopLogger, fx.Provide( globals.New, logger.New, - func() *config.Config { - return &config.Config{ - DataDir: t.TempDir(), - } - }, + func(l *logger.Logger) *slog.Logger { return l.Get() }, + func() *config.Config { return cfg }, database.New, database.NewWebhookDBManager, healthcheck.New, @@ -103,12 +262,20 @@ func newTestApp( func(n *recordingNotifier) delivery.Notifier { return n }, - func() *recordingEvictor { - return &recordingEvictor{} + func() *recordingArchives { + return &recordingArchives{} }, - func(r *recordingEvictor) delivery.WebhookEvictor { + func(r *recordingArchives) delivery.Archives { return r }, + func() *testCircuitBreakers { + return &testCircuitBreakers{} + }, + func(b *testCircuitBreakers) delivery.CircuitBreakers { + return b + }, + metrics.NewRegistry, + metrics.New, middleware.New, delivery.NewGuard, handlers.New, @@ -176,7 +343,7 @@ func TestHandleIndex_Authenticated(t *testing.T) { assert.Equal(t, http.StatusSeeOther, w2.Code) assert.Equal( - t, "/sources", w2.Header().Get("Location"), + t, "/hooks", w2.Header().Get("Location"), ) } @@ -190,16 +357,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) { t.Cleanup(app.RequireStop) - req := httptest.NewRequestWithContext( - context.Background(), http.MethodPost, "/", nil) - w := httptest.NewRecorder() - - cfg, err := h.BuildSlackTargetConfigForTest( - w, req, "http://93.184.216.34/services/T00/B00/xxx", + cfg, errMsg, err := h.BuildSlackTargetConfigForTest( + t.Context(), "http://93.184.216.34/services/T00/B00/xxx", ) require.NoError(t, err) - assert.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, errMsg) assert.Contains(t, cfg, "webhookUrl") } @@ -213,17 +376,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) { t.Cleanup(app.RequireStop) - req := httptest.NewRequestWithContext( - context.Background(), http.MethodPost, "/", nil) - w := httptest.NewRecorder() - - cfg, err := h.BuildSlackTargetConfigForTest( - w, req, "http://169.254.169.254/latest/meta-data/", + cfg, errMsg, err := h.BuildSlackTargetConfigForTest( + t.Context(), "http://169.254.169.254/latest/meta-data/", ) - require.Error(t, err) + require.NoError(t, err) + assert.Contains(t, errMsg, "Invalid target URL") assert.Empty(t, cfg) - assert.Equal(t, http.StatusBadRequest, w.Code) } func TestRenderTemplate(t *testing.T) { @@ -307,39 +466,50 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) { t, http.StatusInternalServerError, w.Code, "a failed render must report a 500", ) - assert.Equal( - t, "Internal server error\n", w.Body.String(), + assert.NotContains( + t, w.Body.String(), partialPageMarker, "the response must carry no part of the aborted page", ) + assert.Contains( + t, w.Body.String(), "500 Internal Server Error", + "a failed render must answer with the error page", + ) } func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { t.Parallel() - var h *handlers.Handlers - - app := newTestApp(t, &h) - app.RequireStart() - - t.Cleanup(app.RequireStop) - - // Empty expiry: the keep-forever default, empty config. - w := httptest.NewRecorder() - cfg, err := h.BuildDatabaseTargetConfigForTest(w, "") + // Empty expiry and rotation: the keep-forever, one-file default, + // empty config. + cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "") require.NoError(t, err) + assert.Empty(t, errMsg) assert.Empty(t, cfg) // Explicit never is stored as config. - w = httptest.NewRecorder() - cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never") + cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "") require.NoError(t, err) + assert.Empty(t, errMsg) assert.JSONEq(t, `{"expiry":"never"}`, cfg) // A positive duration is stored as config. - w = httptest.NewRecorder() - cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h") + cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "") require.NoError(t, err) + assert.Empty(t, errMsg) assert.JSONEq(t, `{"expiry":"720h"}`, cfg) + + // A rotation is stored as config, with or without an expiry. + cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily") + require.NoError(t, err) + assert.Empty(t, errMsg) + assert.JSONEq(t, `{"rotation":"daily"}`, cfg) + + cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest( + "720h", "hourly", + ) + require.NoError(t, err) + assert.Empty(t, errMsg) + assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg) } func TestBuildDatabaseTargetConfig_RejectsBadExpiry( @@ -347,22 +517,31 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry( ) { t.Parallel() - var h *handlers.Handlers - - app := newTestApp(t, &h) - app.RequireStart() - - t.Cleanup(app.RequireStop) - for _, bad := range []string{"nonsense", "7d", "-5h"} { - w := httptest.NewRecorder() - cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad) + cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "") - require.Error(t, err, "expiry %q", bad) - assert.Empty(t, cfg) - assert.Equal( - t, http.StatusBadRequest, w.Code, - "expiry %q should be rejected with 400", bad, + require.NoError(t, err) + assert.Contains( + t, errMsg, "Invalid archive expiry", + "expiry %q should be refused", bad, ) + assert.Empty(t, cfg) + } +} + +func TestBuildDatabaseTargetConfig_RejectsBadRotation( + t *testing.T, +) { + t.Parallel() + + for _, bad := range []string{"weekly", "Daily", " none"} { + cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad) + + require.NoError(t, err) + assert.Contains( + t, errMsg, "Invalid archive rotation", + "rotation %q should be refused", bad, + ) + assert.Empty(t, cfg) } } diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 17272d0..edbfc76 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -5,13 +5,13 @@ import ( ) // HandleIndex returns a handler for the root path that redirects -// based on authentication state: authenticated users go to /sources +// based on authentication state: authenticated users go to /hooks // (the dashboard), unauthenticated users go to the login page. func (s *Handlers) HandleIndex() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess, err := s.session.Get(r) if err == nil && s.session.IsAuthenticated(sess) { - http.Redirect(w, r, "/sources", http.StatusSeeOther) + http.Redirect(w, r, "/hooks", http.StatusSeeOther) return } diff --git a/internal/handlers/logbound_test.go b/internal/handlers/logbound_test.go index abb78f4..239bde3 100644 --- a/internal/handlers/logbound_test.go +++ b/internal/handlers/logbound_test.go @@ -4,7 +4,7 @@ package handlers_test // this package reach a value an UNAUTHENTICATED client picks outright // and of a length it picks outright: // -// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose +// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose // path segment matched no stored entrypoint and so is bounded by // nothing; // - the failed-login DEBUG lines, whose username is a form field. @@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) { // route pattern. func receiverRouter(h *handlers.Handlers) *chi.Mux { router := chi.NewRouter() - router.Post("/webhook/{uuid}", h.HandleWebhook()) + router.Post("/h/{uuid}", h.HandleWebhook()) return router } -// postReceiver sends one POST at /webhook/. +// postReceiver sends one POST at /h/. // // RawPath is cleared after parsing so chi routes on the decoded path // and the handler sees the raw bytes rather than their percent-escaped @@ -210,7 +210,7 @@ func postReceiver( req := httptest.NewRequestWithContext( context.Background(), http.MethodPost, - "/webhook/"+url.PathEscape(segment), + "/h/"+url.PathEscape(segment), strings.NewReader(""), ) req.URL.RawPath = "" @@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple" // storedFillBytes is the raw length of the client-chosen value in // those accounts' usernames. It is well past the 512-byte field // budget, so the line is still truncated, but short enough that the -// session cookie a successful login writes stays inside -// securecookie's 4 KB limit: the cookie is written BEFORE the -// "user logged in" line, so an 8 KB username answers 500 and never -// reaches it. -const storedFillBytes = 1024 +// whole username, markers and fill name included, stays within +// database.MaxUsernameBytes. +const storedFillBytes = 960 // storedFill builds a username fill of storedFillBytes raw bytes out // of repetitions of ch, with both markers at its far end. @@ -509,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize( http.StatusServiceUnavailable, postLoginAtPath( t, h, - "/source/"+url.PathEscape( + "/hook/"+url.PathEscape( oversizedFill(fill), )+"/login", ), diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go new file mode 100644 index 0000000..7ed4166 --- /dev/null +++ b/internal/handlers/metrics.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// HandleMetrics returns the Prometheus scrape handler for the +// registry built by metrics.NewRegistry, which the HTTP, delivery, Go +// runtime and process collectors register on. It is what +// promhttp.Handler builds for the global default registry, including +// the promhttp_metric_handler_* series that count scrapes, pointed at +// that registry instead. +func (s *Handlers) HandleMetrics() http.HandlerFunc { + reg := s.params.Registry + + return promhttp.InstrumentMetricHandler( + reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), + ).ServeHTTP +} diff --git a/internal/handlers/notice.go b/internal/handlers/notice.go new file mode 100644 index 0000000..8e2e2ae --- /dev/null +++ b/internal/handlers/notice.go @@ -0,0 +1,112 @@ +package handlers + +import "net/http" + +// noticeParam is the query parameter an action's redirect carries its +// notice code in. +const noticeParam = "notice" + +// noticeCode names one of the fixed lines noticeFor knows. An action +// redirects with the code rather than the line, so nothing a client +// puts in the URL reaches the page: a code noticeFor does not know +// shows nothing. +type noticeCode string + +// The codes of the actions on the webhook pages and of signing out. +// Replay's codes, with the reasons a replay can be refused, and +// resubmit's codes are defined beside those actions. +const ( + webhookCreated noticeCode = "webhook-created" + webhookSaved noticeCode = "webhook-saved" + webhookDeleted noticeCode = "webhook-deleted" + entrypointAdded noticeCode = "entrypoint-added" + entrypointSaved noticeCode = "entrypoint-saved" + entrypointDeleted noticeCode = "entrypoint-deleted" + entrypointActivated noticeCode = "entrypoint-activated" + entrypointDeactivated noticeCode = "entrypoint-deactivated" + targetAdded noticeCode = "target-added" + targetSaved noticeCode = "target-saved" + targetDeleted noticeCode = "target-deleted" + targetActivated noticeCode = "target-activated" + targetDeactivated noticeCode = "target-deactivated" + signedOut noticeCode = "signed-out" +) + +// notice is the line templates/notice.html shows above a page to say +// what an action did. +type notice struct { + Text string + + // Failed shows the line as an error: the action was refused. + Failed bool +} + +// noticeFor returns the notice the request's URL names, or nil when it +// names none or an unknown code. +func noticeFor(r *http.Request) *notice { + n, ok := map[noticeCode]notice{ + webhookCreated: {Text: "Webhook created."}, + webhookSaved: {Text: "Webhook saved."}, + webhookDeleted: {Text: "Webhook deleted."}, + entrypointAdded: {Text: "Entrypoint added."}, + entrypointSaved: {Text: "Entrypoint description saved."}, + entrypointDeleted: {Text: "Entrypoint deleted."}, + entrypointActivated: {Text: "Entrypoint activated."}, + entrypointDeactivated: {Text: "Entrypoint deactivated."}, + targetAdded: {Text: "Target added."}, + targetSaved: {Text: "Target saved."}, + targetDeleted: {Text: "Target deleted."}, + targetActivated: {Text: "Target activated."}, + targetDeactivated: {Text: "Target deactivated."}, + signedOut: {Text: "Signed out."}, + + replayQueued: { + Text: "Replay queued: a new delivery was created " + + "against the target's current configuration.", + }, + replayTargetDeleted: { + Text: "Not replayed: the target this delivery was for " + + "has been deleted. Use Resubmit to send the event " + + "to the webhook's currently active targets.", + Failed: true, + }, + replayTargetMissing: { + Text: "Not replayed: the target this delivery was for " + + "no longer exists.", + Failed: true, + }, + replayTargetInactive: { + Text: "Not replayed: the target this delivery was for " + + "is deactivated. Activate it, then replay.", + Failed: true, + }, + replayNotTerminal: { + Text: "Not replayed: this delivery has not finished yet.", + Failed: true, + }, + replayInFlight: { + Text: "Not replayed: a delivery of this event to this " + + "target is already in flight.", + Failed: true, + }, + + resubmitQueued: { + Text: "Resubmitted: a new event was created from the " + + "stored one and queued to every active target.", + }, + resubmitNoTargets: { + Text: "Resubmitted: a new event was created, but this " + + "webhook has no active targets, so nothing was queued.", + }, + }[noticeCode(r.URL.Query().Get(noticeParam))] + if !ok { + return nil + } + + return &n +} + +// withNotice returns path with code added as its notice. +func withNotice(path string, code noticeCode) string { + return path + "?" + noticeParam + "=" + string(code) +} diff --git a/internal/handlers/page_title_test.go b/internal/handlers/page_title_test.go new file mode 100644 index 0000000..441bd1a --- /dev/null +++ b/internal/handlers/page_title_test.go @@ -0,0 +1,111 @@ +package handlers_test + +import ( + "html/template" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" + "sneak.berlin/go/webhooker/templates" +) + +// TestEveryPageRendersItsOwnTitle renders each page template and checks +// the browser tab title is the one the page declares, not the +// "Webhooker" fallback in htmlheader.html. A page that fails to render +// shows the error page's title instead, and fails here too. +func TestEveryPageRendersItsOwnTitle(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + var sess *session.Session + + app := newTestApp(t, &h, &sess) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + // A pointer, as in the handlers: some pages call + // Webhook.RetentionLabel, a pointer method. + webhook := &database.Webhook{Name: "orders", RetentionDays: 14} + webhook.ID = testWebhookID + + pages := []struct { + page string + data map[string]any + title string + }{ + {"login.html", map[string]any{}, "Sign in - Webhooker"}, + {"profile.html", map[string]any{}, "Profile - Webhooker"}, + {"settings.html", map[string]any{}, "Settings - Webhooker"}, + {"sources_list.html", map[string]any{}, "Webhooks - Webhooker"}, + {"sources_new.html", map[string]any{}, "New Webhook - Webhooker"}, + { + "source_detail.html", + map[string]any{dataKeyWebhook: webhook}, + "orders - Webhooker", + }, + { + "source_edit.html", + map[string]any{dataKeyWebhook: webhook}, + "Edit orders - Webhooker", + }, + { + "source_logs.html", + map[string]any{ + dataKeyWebhook: webhook, + dataKeyEvents: []handlers.EventLogView{}, + "TotalEvents": int64(0), + }, + "Full Event Log - orders - Webhooker", + }, + { + "event_detail.html", + map[string]any{dataKeyWebhook: webhook}, + "Event - orders - Webhooker", + }, + { + "target_edit.html", + map[string]any{ + dataKeyWebhook: webhook, + "Target": map[string]any{"Name": "alerts", "Type": "slack"}, + }, + "Edit alerts - Webhooker", + }, + { + "error.html", + map[string]any{"StatusText": http.StatusText(http.StatusNotFound)}, + "Not Found - Webhooker", + }, + } + + for _, p := range pages { + body := renderPage(t, h, sess, p.page, p.data) + + _, afterOpen, _ := strings.Cut(body, "") + title, _, _ := strings.Cut(afterOpen, "") + + assert.Equal(t, p.title, title, p.page) + } +} + +// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a +// page that declares none. Every page declares one, so it is checked on +// htmlheader.html alone. +func TestTitleFallbackIsWebhooker(t *testing.T) { + t.Parallel() + + header := template.Must( + template.ParseFS(templates.Templates, "htmlheader.html"), + ) + + var buf strings.Builder + + require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil)) + assert.Contains(t, buf.String(), "Webhooker") +} diff --git a/internal/handlers/profile.go b/internal/handlers/profile.go index 9d929b4..71fc163 100644 --- a/internal/handlers/profile.go +++ b/internal/handlers/profile.go @@ -1,7 +1,6 @@ package handlers import ( - "context" "net/http" "github.com/go-chi/chi" @@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc { err := r.ParseForm() if err != nil { h.log.Error("failed to parse form", "error", err) - http.Error(w, "Bad request", http.StatusBadRequest) + h.renderError(w, r, http.StatusBadRequest) return } successMessage, errorMessage, handled := h.applyPasswordChange( - r.Context(), w, + r, sessionUsername, // PostFormValue, not FormValue: the credential must // come from the body, never from the query string. @@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc { // applyPasswordChange verifies the current password and, on success, // persists a fresh hash for the user, reusing the same helpers that // bootstrap the admin user. It returns the success and error messages -// to display on the profile page. On an internal failure it writes a -// 500 response itself and returns handled=false, signalling the caller +// to display on the profile page. On an internal failure it writes the +// error page itself and returns handled=false, signalling the caller // to stop without re-rendering the page. func (h *Handlers) applyPasswordChange( - ctx context.Context, w http.ResponseWriter, + r *http.Request, username, currentPassword, newPassword, confirmPassword string, ) (string, string, bool) { // This endpoint verifies one password and hashes another, at @@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange( // endpoint uses. The bound is per hash, not per endpoint: leaving // this path outside it would leave a hole in it. The slot is held // across both hashes. - release, ok := h.mw.BeginPasswordVerification(ctx) + release, ok := h.mw.BeginPasswordVerification(r.Context()) if !ok { h.log.Warn("password verification capacity exhausted") - http.Error( - w, - "The server is busy verifying credentials. "+ - "Please try again.", - http.StatusServiceUnavailable, - ) + h.renderError(w, r, http.StatusServiceUnavailable) return "", "", false } @@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange( ).First(&user).Error if err != nil { h.serverError( - w, "failed to load user for password change", err, + w, r, "failed to load user for password change", err, ) return "", "", false @@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange( currentPassword, user.Password, ) if err != nil { - h.serverError(w, "failed to verify password", err) + h.serverError(w, r, "failed to verify password", err) return "", "", false } @@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange( hashedPassword, err := database.HashPassword(newPassword) if err != nil { - h.serverError(w, "failed to hash new password", err) + h.serverError(w, r, "failed to hash new password", err) return "", "", false } @@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange( "password", hashedPassword, ).Error if err != nil { - h.serverError(w, "failed to update password", err) + h.serverError(w, r, "failed to update password", err) return "", "", false } @@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny( ) (string, string, bool) { requestedUsername := chi.URLParam(r, "username") if requestedUsername == "" { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return "", "", false } @@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny( // unexpected retrieval error. sess, err := h.session.Get(r) if err != nil { - h.serverError(w, "failed to get session", err) + h.serverError(w, r, "failed to get session", err) return "", "", false } @@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny( sessionUsername, ok := h.session.GetUsername(sess) if !ok { h.log.Error("authenticated session missing username") - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.renderError(w, r, http.StatusInternalServerError) return "", "", false } @@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny( sessionUserID, ok := h.session.GetUserID(sess) if !ok { h.log.Error("authenticated session missing user ID") - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) + h.renderError(w, r, http.StatusInternalServerError) return "", "", false } // Only allow users to act on their own profile. if requestedUsername != sessionUsername { - http.Error(w, "Forbidden", http.StatusForbidden) + h.renderError(w, r, http.StatusForbidden) return "", "", false } diff --git a/internal/handlers/profile_test.go b/internal/handlers/profile_test.go index 18692c3..8468358 100644 --- a/internal/handlers/profile_test.go +++ b/internal/handlers/profile_test.go @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/logger" - "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/session" ) @@ -88,6 +88,8 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) { h.HandleProfile().ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "Account Information") + assert.NotContains(t, w.Body.String(), "Account Type") } func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) { @@ -126,18 +128,20 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) { var sess *session.Session - app := newTestApp(t, &log, &cfg, &sess) + var h *handlers.Handlers + + app := newTestApp(t, &log, &cfg, &sess, &h) app.RequireStart() t.Cleanup(app.RequireStop) - mw := middleware.NewForTest(log.Get(), cfg, sess) + mw := middlewaretest.New(t, log.Get(), cfg, sess) var handlerReached bool router := chi.NewRouter() router.Route("/user/{username}", func(r chi.Router) { - r.Use(mw.CSRF()) + r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden))) r.Use(mw.RequireAuth()) r.Get("/", func(w http.ResponseWriter, _ *http.Request) { handlerReached = true @@ -158,7 +162,10 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) { "handler must not be reached for unauthenticated request", ) assert.Equal(t, http.StatusSeeOther, w.Code) - assert.Equal(t, "/pages/login", w.Header().Get("Location")) + assert.Equal( + t, "/pages/login?next=%2Fuser%2Ftestuser", + w.Header().Get("Location"), + ) } // passwordChangeRequest builds a POST request to the password-change diff --git a/internal/handlers/recent_events.go b/internal/handlers/recent_events.go new file mode 100644 index 0000000..d272419 --- /dev/null +++ b/internal/handlers/recent_events.go @@ -0,0 +1,305 @@ +package handlers + +import ( + "net/http" + "slices" + "strconv" + "time" + + "github.com/dustin/go-humanize" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// recentEventColumns is the recent events list's projection. It +// reads the body cut to maxRenderedBodyBytes, as eventLogColumns +// does, and its size from body_bytes, recorded when the event was +// stored. +const recentEventColumns = "id, created_at, method, content_type, " + + "resubmitted_from_id, body_bytes, " + + "substr(cast(body as blob), 1, ?) AS body" + +// recentAttemptColumns is the part of a recorded attempt the list +// uses. The event log's deliveryResultColumns also reads response +// bodies, which the list does not show. +const recentAttemptColumns = "delivery_id, status_code, created_at" + +// RecentEventView is one row of the recent events list on a +// webhook's page. +type RecentEventView struct { + Method string + ContentType string + + // ResubmittedFromID names the event this one was copied from, + // empty for an event that arrived on the receiver. + ResubmittedFromID string + + // Received is how long ago the event arrived, and ReceivedUTC + // the full timestamp the page shows on hover. + Received string + ReceivedUTC string + + // Size is the size of the stored body. + Size string + + // ProcessingTime is how long the event's slowest delivery + // took; see processingTime. + ProcessingTime string + + // Status is what the webhook's HTTP target answered, and + // StatusClass its colour; see targetStatus. Both are empty + // unless the webhook has exactly one HTTP target. + Status string + StatusClass string + + // Body is what the row shows when it is expanded. + Body BodyView +} + +// recentEventRow is one row of recentEventColumns. +type recentEventRow struct { + ID string + CreatedAt time.Time + Method string + ContentType string + ResubmittedFromID *string + BodyBytes uint64 + Body []byte +} + +// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is +// when the attempt's result was recorded, which is when the attempt +// finished. +type recentAttemptRow struct { + DeliveryID string + StatusCode int + CreatedAt time.Time +} + +// singleHTTPTargetID returns the ID of the webhook's HTTP target +// when it has exactly one, and "" when it has none or several. +func singleHTTPTargetID(targets []database.Target) string { + id := "" + count := 0 + + for i := range targets { + if targets[i].Type == database.TargetTypeHTTP { + id = targets[i].ID + count++ + } + } + + if count != 1 { + return "" + } + + return id +} + +// loadRecentEvents loads the webhook's recentEventLimit newest +// events for its page, newest first. statusTargetID is the +// webhook's only HTTP target, or "" when the list shows no status. +func loadRecentEvents( + webhookDB *gorm.DB, webhookID, statusTargetID string, +) ([]RecentEventView, error) { + var rows []recentEventRow + + err := webhookDB.Model(&database.Event{}). + Select(recentEventColumns, maxRenderedBodyBytes). + Where("webhook_id = ?", webhookID). + Order("created_at DESC"). + Limit(recentEventLimit). + Find(&rows).Error + if err != nil { + return nil, err + } + + eventIDs := make([]string, len(rows)) + for i := range rows { + eventIDs[i] = rows[i].ID + } + + // Oldest first, so an event's last delivery to a target is its + // newest: a replay adds a delivery rather than changing the + // earlier one. + var deliveries []database.Delivery + + err = webhookDB. + Select("id, event_id, target_id, status, created_at"). + Where("event_id IN ?", eventIDs). + Order("created_at ASC"). + Find(&deliveries).Error + if err != nil { + return nil, err + } + + byEvent := make(map[string][]database.Delivery, len(rows)) + deliveryIDs := make([]string, len(deliveries)) + + for i := range deliveries { + eventID := deliveries[i].EventID + byEvent[eventID] = append(byEvent[eventID], deliveries[i]) + deliveryIDs[i] = deliveries[i].ID + } + + attempts, err := loadRecentAttempts(webhookDB, deliveryIDs) + if err != nil { + return nil, err + } + + views := make([]RecentEventView, len(rows)) + for i := range rows { + views[i] = rows[i].view( + webhookID, byEvent[rows[i].ID], attempts, statusTargetID, + ) + } + + return views, nil +} + +// loadRecentAttempts loads the recorded attempts of the listed +// events' deliveries, keyed by delivery ID, each delivery's in +// attempt order. The IDs go in chunks for the reason +// deliveryIDChunkSize gives. +func loadRecentAttempts( + webhookDB *gorm.DB, deliveryIDs []string, +) (map[string][]recentAttemptRow, error) { + byDelivery := make(map[string][]recentAttemptRow) + + for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) { + var rows []recentAttemptRow + + err := webhookDB.Model(&database.DeliveryResult{}). + Select(recentAttemptColumns). + Where("delivery_id IN ?", chunk). + Order("attempt_num ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + + for i := range rows { + id := rows[i].DeliveryID + byDelivery[id] = append(byDelivery[id], rows[i]) + } + } + + return byDelivery, nil +} + +// view projects a loaded row of the webhook's events for +// rendering. deliveries is the event's deliveries, oldest first, +// and attempts their recorded attempts keyed by delivery ID. +func (r *recentEventRow) view( + webhookID string, + deliveries []database.Delivery, + attempts map[string][]recentAttemptRow, + statusTargetID string, +) RecentEventView { + //nolint:gosec // body_bytes is at most the receiver's 1 MB cap + body := newBodyView( + "/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes), + ) + + v := RecentEventView{ + Method: r.Method, + ContentType: r.ContentType, + Received: humanize.Time(r.CreatedAt), + ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC", + Size: humanize.Bytes(r.BodyBytes), + ProcessingTime: processingTime(deliveries, attempts), + Body: body, + } + + if r.ResubmittedFromID != nil { + v.ResubmittedFromID = *r.ResubmittedFromID + } + + if statusTargetID != "" { + v.Status, v.StatusClass = targetStatus( + deliveries, attempts, statusTargetID, + ) + } + + return v +} + +// processingTime is how long the event's slowest delivery took, +// from being queued to its last recorded attempt, time spent +// waiting between retries included. A delivery is queued when its +// event is received, or when an operator replays it, so a replay +// is timed from the replay rather than from the event's arrival. +// It is "in progress" while any delivery is pending or retrying, +// and empty for an event with no deliveries. +func processingTime( + deliveries []database.Delivery, + attempts map[string][]recentAttemptRow, +) string { + if len(deliveries) == 0 { + return "" + } + + var slowest time.Duration + + for i := range deliveries { + if !deliveries[i].Status.Terminal() { + return "in progress" + } + + tries := attempts[deliveries[i].ID] + if len(tries) == 0 { + continue + } + + last := tries[len(tries)-1].CreatedAt + slowest = max(slowest, last.Sub(deliveries[i].CreatedAt)) + } + + return slowest.Round(time.Millisecond).String() +} + +// targetStatus is what the target answered for the event, and the +// colour to show it in: the HTTP status code of the last attempt of +// the event's newest delivery to the target. Without a code it is +// "no response" when that attempt failed before a response +// arrived, the delivery's status ("pending") before any attempt, +// and "not sent" when the event has no delivery to the target. +func targetStatus( + deliveries []database.Delivery, + attempts map[string][]recentAttemptRow, + targetID string, +) (string, string) { + newest := -1 + + for i := range deliveries { + if deliveries[i].TargetID == targetID { + newest = i + } + } + + if newest < 0 { + return "not sent", "text-gray-400" + } + + tries := attempts[deliveries[newest].ID] + if len(tries) == 0 { + return string(deliveries[newest].Status), "text-gray-400" + } + + code := tries[len(tries)-1].StatusCode + + switch { + case code == 0: + return "no response", "text-red-600" + case code >= http.StatusInternalServerError: + return strconv.Itoa(code), "text-red-600" + case code >= http.StatusBadRequest: + return strconv.Itoa(code), "text-yellow-600" + case code >= http.StatusMultipleChoices: + return strconv.Itoa(code), "text-gray-500" + case code >= http.StatusOK: + return strconv.Itoa(code), "text-green-600" + default: + return strconv.Itoa(code), "text-gray-500" + } +} diff --git a/internal/handlers/recent_events_test.go b/internal/handlers/recent_events_test.go new file mode 100644 index 0000000..99ef59d --- /dev/null +++ b/internal/handlers/recent_events_test.go @@ -0,0 +1,430 @@ +package handlers_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// statusTitle marks the status column's cell in a recent events +// row; it is absent from the page when the column is not shown. +const statusTitle = `title="HTTP status from the HTTP target"` + +// recentEventsFixture is one started app and a webhook whose +// recent events list a test fills. +type recentEventsFixture struct { + h *handlers.Handlers + sess *session.Session + db *database.Database + webhook *database.Webhook + webhookDB *gorm.DB +} + +func newRecentEventsFixture(t *testing.T) *recentEventsFixture { + t.Helper() + + f := &recentEventsFixture{} + + var dbMgr *database.WebhookDBManager + + app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + f.webhook = seedWebhook(t, f.db) + + webhookDB, err := dbMgr.GetDB(f.webhook.ID) + require.NoError(t, err) + + f.webhookDB = webhookDB + + return f +} + +func (f *recentEventsFixture) render(t *testing.T) string { + t.Helper() + + return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID) +} + +// event records an event received at receivedAt, with its body's +// size as the receiver records it. +func (f *recentEventsFixture) event( + t *testing.T, contentType, body string, receivedAt time.Time, +) *database.Event { + t.Helper() + + event := &database.Event{ + WebhookID: f.webhook.ID, + Method: http.MethodPost, + Body: body, + BodyBytes: int64(len(body)), + ContentType: contentType, + } + event.CreatedAt = receivedAt + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(event).Error) + + return event +} + +// delivery records a delivery of the event to the target, queued +// when the event was received. +func (f *recentEventsFixture) delivery( + t *testing.T, + event *database.Event, + targetID string, + status database.DeliveryStatus, +) *database.Delivery { + t.Helper() + + return f.deliveryQueuedAt( + t, event, targetID, status, event.CreatedAt, + ) +} + +// deliveryQueuedAt records a delivery of the event to the target, +// queued at queuedAt, as a replay is. +func (f *recentEventsFixture) deliveryQueuedAt( + t *testing.T, + event *database.Event, + targetID string, + status database.DeliveryStatus, + queuedAt time.Time, +) *database.Delivery { + t.Helper() + + dlv := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: status, + } + dlv.CreatedAt = queuedAt + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(dlv).Error) + + return dlv +} + +// attempt records one attempt of the delivery that finished took +// after the delivery was queued, with HTTP status code (0 for no +// response). +func (f *recentEventsFixture) attempt( + t *testing.T, dlv *database.Delivery, code int, took time.Duration, +) { + t.Helper() + + result := &database.DeliveryResult{ + DeliveryID: dlv.ID, + AttemptNum: 1, + StatusCode: code, + } + result.CreatedAt = dlv.CreatedAt.Add(took) + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(result).Error) +} + +// statusCell is the status column's cell as the page renders it. +func statusCell(class, text string) string { + return `` + text + `` +} + +// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is +// headed "50 Most Recent Events" and holds the 50 newest events, +// newest first, and not one more. +func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + f.event( + t, fmt.Sprintf("application/x-recent-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + } + + body := f.render(t) + + assert.Contains(t, body, ">50 Most Recent Events") + assert.Equal(t, 50, strings.Count(body, `title="Body size"`)) + assert.NotContains(t, body, "application/x-recent-00") + assert.Contains(t, body, "application/x-recent-01") + assert.Less( + t, + strings.Index(body, "application/x-recent-50"), + strings.Index(body, "application/x-recent-49"), + ) +} + +// TestHandleSourceDetail_RecentEventColumns proves a row shows its +// time relative with the UTC timestamp on hover, its body size, +// and its processing time once every delivery has finished. +func TestHandleSourceDetail_RecentEventColumns(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + + receivedAt := time.Now().Add(-210 * time.Second). + UTC().Truncate(time.Second) + + done := f.event( + t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt, + ) + f.attempt( + t, + f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered), + 0, 1500*time.Millisecond, + ) + + waiting := f.event(t, "text/plain", "{}", receivedAt) + f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending) + + body := f.render(t) + + assert.Contains( + t, body, + `3 minutes ago`, + ) + assert.Contains(t, body, `2.0 kB`) + assert.Contains(t, body, ">1.5s") + assert.Contains(t, body, ">in progress") +} + +// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a +// webhook with exactly one HTTP target shows, colour-coded, what +// that target answered for each event. The log target beside it +// does not count against "exactly one". +func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) + seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + + now := time.Now() + + for _, code := range []int{204, 302, 404, 503, 0} { + dlv := f.delivery( + t, f.event(t, contentTypeJSON, "{}", now), target.ID, + database.DeliveryStatusDelivered, + ) + f.attempt(t, dlv, code, time.Second) + } + + f.delivery( + t, f.event(t, contentTypeJSON, "{}", now), target.ID, + database.DeliveryStatusPending, + ) + f.event(t, contentTypeJSON, "{}", now) + + // A replay is a newer delivery, and its answer is the one shown. + replayed := f.event(t, contentTypeJSON, "{}", now) + f.attempt(t, f.delivery( + t, replayed, target.ID, database.DeliveryStatusFailed, + ), 502, time.Second) + f.attempt(t, f.deliveryQueuedAt( + t, replayed, target.ID, database.DeliveryStatusDelivered, + now.Add(time.Minute), + ), 200, time.Second) + + body := f.render(t) + + assert.Contains(t, body, statusCell("text-green-600", "204")) + assert.Contains(t, body, statusCell("text-gray-500", "302")) + assert.Contains(t, body, statusCell("text-yellow-600", "404")) + assert.Contains(t, body, statusCell("text-red-600", "503")) + assert.Contains(t, body, statusCell("text-red-600", "no response")) + assert.Contains(t, body, statusCell("text-gray-400", "pending")) + assert.Contains(t, body, statusCell("text-gray-400", "not sent")) + assert.Contains(t, body, statusCell("text-green-600", "200")) + assert.NotContains(t, body, ">502<") +} + +// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the +// status column is absent when the webhook has no HTTP target or +// more than one. +func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget( + t *testing.T, +) { + t.Parallel() + + cases := map[string][]database.TargetType{ + "none": {database.TargetTypeLog}, + "several": {database.TargetTypeHTTP, database.TargetTypeHTTP}, + } + + for name, types := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + event := f.event(t, contentTypeJSON, "{}", time.Now()) + + for _, tt := range types { + target := seedTarget(t, f.db, f.webhook.ID, tt) + f.attempt(t, f.delivery( + t, event, target.ID, + database.DeliveryStatusDelivered, + ), 200, time.Second) + } + + body := f.render(t) + + assert.Contains(t, body, `title="Body size"`) + assert.NotContains(t, body, statusTitle) + }) + } +} + +// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row +// links to its event's own page and expands to show its body, and +// that only the newest row starts expanded. +func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + now := time.Now() + + older := f.event( + t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute), + ) + newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now) + + body := f.render(t) + + for _, e := range []*database.Event{older, newer} { + assert.Contains( + t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`, + ) + } + + assert.Equal(t, 2, strings.Count(body, `
`)) + assert.Equal(t, 1, strings.Count(body, " data-open>")) + + open := strings.Index(body, " data-open>") + newerBody := strings.Index(body, ""which": "newer"") + olderBody := strings.Index(body, ""which": "older"") + + assert.Less(t, open, newerBody, "the newest row is not the open one") + assert.Less(t, newerBody, olderBody) +} + +// TestHandleSourceDetail_RecentEventBodyCut proves a body up to +// the cap is shown whole and pretty-printed, and a larger one only +// its first bodyCap bytes, as received, with links to the whole +// body on the event's page and to the download. +func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + now := time.Now() + + // A JSON document of n bytes. + document := func(n int) string { + return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}` + } + + whole := f.event( + t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute), + ) + cut := f.event(t, contentTypeJSON, document(bodyCap+1), now) + + body := f.render(t) + eventURL := `href="/hook/` + f.webhook.ID + `/events/` + + assert.Equal(t, 1, strings.Count(body, "{\n "pad": ")) + assert.Contains(t, body, "{"pad":"xxx") + assert.Contains( + t, body, + "Showing the first "+strconv.Itoa(bodyCap)+" of "+ + strconv.Itoa(bodyCap+1)+" bytes, unformatted.", + ) + assert.Contains(t, body, eventURL+cut.ID+`/body"`) + assert.NotContains(t, body, eventURL+whole.ID+`/body"`) +} + +// TestHandleWebhook_RecordsBodySize proves the receiver records the +// body's size in bytes, not characters, with the event it stores. +func TestHandleWebhook_RecordsBodySize(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + seedEntrypoint(t, f.db, f.webhook.ID) + + // Two bytes per character. + body := strings.Repeat("é", 1024) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, "/h/x", + strings.NewReader(body), + ) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID) + + req = req.WithContext(context.WithValue( + req.Context(), chi.RouteCtxKey, rctx, + )) + + w := httptest.NewRecorder() + f.h.HandleWebhook().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var stored database.Event + + require.NoError(t, f.webhookDB.First(&stored).Error) + assert.Equal(t, int64(2048), stored.BodyBytes) +} + +// TestHandleSourceDetail_FailedLoadIsAnError proves that when the +// list cannot be loaded the page answers with an error, rather than +// an empty list claiming the webhook has no events. +func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) + + f.attempt(t, f.delivery( + t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID, + database.DeliveryStatusDelivered, + ), 200, time.Second) + + // The attempts are the list's last query, so its events and + // deliveries have already loaded when it fails. + require.NoError(t, f.webhookDB.Exec( + "DROP TABLE delivery_results", + ).Error) + + w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.NotContains(t, w.Body.String(), "No events received yet.") +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..1fa235f --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "net/http" + "net/netip" + "strconv" + "strings" + + "sneak.berlin/go/webhooker/internal/config" +) + +// notSet is what the Settings page shows for a value that is empty. +const notSet = "not set" + +// settingRow is one line of the Settings page: an environment +// variable, what it controls, and the value the server loaded for it. +type settingRow struct { + Name string + Description string + Value string +} + +// HandleSettings returns a handler for the read-only Settings page, +// which lists the configuration the server started with. +func (h *Handlers) HandleSettings() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h.renderTemplate(w, r, "settings.html", map[string]any{ + "Settings": settingRows(h.params.Config), + }) + } +} + +// settingRows lists every field of cfg under the environment variable +// it is read from, with the description the README's configuration +// table gives it (less its pointers to other README sections), in the +// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so +// their values never reach the page: only whether they are set. +func settingRows(cfg *config.Config) []settingRow { + metricsUsername := cfg.MetricsUsername + if metricsUsername == "" { + metricsUsername = notSet + } + + return []settingRow{ + {"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment}, + {"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)}, + { + "BIND_ADDRESS", + "IP address the HTTP listener binds. Loopback by default, " + + "so the cleartext listener is not published on every " + + "interface. The Docker image ships 0.0.0.0 instead", + cfg.BindAddress, + }, + {"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir}, + {"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)}, + { + "METRICS_USERNAME", + "Basic auth username for /metrics. Must be set together " + + "with METRICS_PASSWORD; one without the other fails " + + "startup", + metricsUsername, + }, + { + "METRICS_PASSWORD", + "Basic auth password for /metrics. Must be set together " + + "with METRICS_USERNAME; one without the other fails " + + "startup", + setOrNotSet(cfg.MetricsPassword), + }, + { + "SENTRY_DSN", + "Sentry error reporting DSN. Unset leaves error reporting " + + "off; a value the Sentry SDK cannot parse fails startup " + + "rather than serving with reporting silently off", + setOrNotSet(cfg.SentryDSN), + }, + { + "RETENTION_SWEEP_INTERVAL", + "How often the retention reaper and archive sweeper run " + + "(Go duration, must be positive). A value that does " + + "not parse, or is zero or negative, fails startup", + cfg.RetentionSweepInterval.String(), + }, + { + "SESSION_IDLE_TIMEOUT", + "Idle session timeout (Go duration)", + cfg.SessionIdleTimeout.String(), + }, + { + "HSTS_MAX_AGE", + "max-age of the Strict-Transport-Security header, which " + + "also carries includeSubDomains and preload (Go " + + "duration, whole seconds). 0 sends no header", + cfg.HSTSMaxAge.String(), + }, + { + "RECEIVER_RATE_LIMIT", + "Receiver requests/minute per IP per entrypoint " + + "(10x that per IP across the route)", + strconv.Itoa(cfg.ReceiverRateLimit), + }, + { + "TRUSTED_PROXIES", + "CIDRs whose forwarded headers are trusted. A set value " + + "replaces the default. If any client can reach webhooker, " + + "or the proxy in front of it, from an RFC 1918 source " + + "address, set it to the proxy's address alone", + cidrList(cfg.TrustedProxies), + }, + { + "ALLOWED_EGRESS_CIDRS", + "CIDRs that delivery targets may reach despite the " + + "SSRF blocklist", + cidrList(cfg.AllowedEgressCIDRs), + }, + } +} + +// setOrNotSet is how the Settings page shows a credential: whether it +// has a value, never the value itself. +func setOrNotSet(value string) string { + if value == "" { + return notSet + } + + return "set" +} + +// cidrList renders a CIDR list setting for the Settings page. +func cidrList(prefixes []netip.Prefix) string { + if len(prefixes) == 0 { + return "none" + } + + return strings.Join(config.PrefixStrings(prefixes), ", ") +} diff --git a/internal/handlers/settings_test.go b/internal/handlers/settings_test.go new file mode 100644 index 0000000..504bb7a --- /dev/null +++ b/internal/handlers/settings_test.go @@ -0,0 +1,150 @@ +package handlers_test + +import ( + "context" + "html" + "net/http" + "net/http/httptest" + "net/netip" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// settingsShown renders the Settings page over cfg as a logged-in user +// and returns the value it shows for each variable name, plus the +// whole page. +func settingsShown( + t *testing.T, cfg *config.Config, +) (map[string]string, string) { + t.Helper() + + var h *handlers.Handlers + + var sess *session.Session + + app := newTestAppWithConfig(t, cfg, &h, &sess) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/settings", nil, + ) + for _, c := range authenticatedCookies(t, sess, "id", "admin") { + req.AddCookie(c) + } + + w := httptest.NewRecorder() + h.HandleSettings().ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + body := w.Body.String() + + row := regexp.MustCompile( + `]*>([A-Z_]+)\s*]*>([^<]*)`, + ) + + shown := map[string]string{} + for _, match := range row.FindAllStringSubmatch(body, -1) { + shown[match[1]] = html.UnescapeString(match[2]) + } + + return shown, body +} + +func TestSettingsPageShowsLoadedConfiguration(t *testing.T) { + t.Parallel() + + // Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the + // only one of the three set in one of the content tests, so each + // row is checked against its own field. + cfg := &config.Config{ + DataDir: t.TempDir(), + Debug: true, + Environment: config.EnvironmentDev, + MetricsUsername: "scraper", + MetricsPassword: "", + Port: 9123, + SentryDSN: "", + BindAddress: "192.0.2.10", + RetentionSweepInterval: 17 * time.Minute, + SessionIdleTimeout: 3 * time.Hour, + HSTSMaxAge: 8760 * time.Hour, + ReceiverRateLimit: 77, + TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("10.1.0.0/16"), + }, + AllowedEgressCIDRs: []netip.Prefix{ + netip.MustParsePrefix("192.168.5.0/24"), + netip.MustParsePrefix("fd00::/8"), + }, + } + + shown, body := settingsShown(t, cfg) + + assert.Equal(t, map[string]string{ + "WEBHOOKER_ENVIRONMENT": "dev", + "PORT": "9123", + "BIND_ADDRESS": "192.0.2.10", + "DATA_DIR": cfg.DataDir, + "DEBUG": "true", + "METRICS_USERNAME": "scraper", + "METRICS_PASSWORD": "not set", + "SENTRY_DSN": "not set", + "RETENTION_SWEEP_INTERVAL": "17m0s", + "SESSION_IDLE_TIMEOUT": "3h0m0s", + "HSTS_MAX_AGE": "8760h0m0s", + "RECEIVER_RATE_LIMIT": "77", + "TRUSTED_PROXIES": "10.1.0.0/16", + "ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8", + }, shown) + + assert.Contains( + t, body, `href="/settings"`, + "the navigation bar links to the page", + ) +} + +func TestSettingsPageShowsUnsetValues(t *testing.T) { + t.Parallel() + + const metricsPassword = "metrics-password-1f9a" + + shown, body := settingsShown(t, &config.Config{ + DataDir: t.TempDir(), + MetricsPassword: metricsPassword, + }) + + assert.Equal(t, "not set", shown["METRICS_USERNAME"]) + assert.Equal(t, "set", shown["METRICS_PASSWORD"]) + assert.Equal(t, "not set", shown["SENTRY_DSN"]) + assert.NotContains(t, body, metricsPassword) + assert.Equal(t, "none", shown["TRUSTED_PROXIES"]) + assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"]) +} + +func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) { + t.Parallel() + + const ( + sentryKey = "dsnkey7c2e" + sentryDSN = "https://" + sentryKey + "@errors.example.com/42" + ) + + shown, body := settingsShown(t, &config.Config{ + DataDir: t.TempDir(), + SentryDSN: sentryDSN, + }) + + assert.Equal(t, "not set", shown["METRICS_USERNAME"]) + assert.Equal(t, "not set", shown["METRICS_PASSWORD"]) + assert.Equal(t, "set", shown["SENTRY_DSN"]) + assert.NotContains(t, body, sentryKey) +} diff --git a/internal/handlers/shared.go b/internal/handlers/shared.go new file mode 100644 index 0000000..43e13fc --- /dev/null +++ b/internal/handlers/shared.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// parseRetentionDays interprets a retention_days form value. It +// returns the number of days, or, for a value it refuses, the message +// the create and edit forms show; the message is empty when the value +// is accepted. +// +// An empty value yields fallback, which lets the create path apply the +// default and the edit path leave the stored value unchanged. A value +// of 0 is returned as 0 and is rewritten to the retain-forever +// sentinel by database.Webhook's BeforeSave hook. Anything unparseable +// or negative is refused rather than silently given a default. +// +// The upper bound is not cosmetic. The reaper computes its cutoff as a +// time.Duration, an int64 nanosecond count, so a day count above +// database.MaxFiniteRetentionDays overflows, puts the cutoff in the +// future, and deletes every event the webhook has. A finite value +// above that ceiling is therefore refused, and the message names the +// ceiling rather than implying the input was not a number. +// +// A value at or above the retain-forever sentinel is not out of range: +// it is what the edit form pre-fills for a retain-forever webhook, so +// submitting the form back unchanged has to keep meaning "forever" +// rather than being rejected. +func parseRetentionDays(raw string, fallback int) (int, string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, "" + } + + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return 0, "Retention must be a whole number of days, or 0 to " + + "retain events forever." + } + + if v >= database.RetentionForeverDays { + return database.RetentionForeverDays, "" + } + + if v > database.MaxFiniteRetentionDays { + return 0, "Retention must be at most " + + strconv.Itoa(database.MaxFiniteRetentionDays) + + " days, or 0 to retain events forever." + } + + return v, "" +} + +// ownedWebhook resolves the request's sourceID parameter to a +// webhook the session's user owns. +// +// Ownership and existence are decided by one query, so a +// webhook belonging to another user is indistinguishable from +// one that does not exist: both are a 404, and neither confirms +// the id. Callers that reach further into a webhook's data — +// the event log page and the event body download — share this +// one check rather than restating it, so the download cannot +// come to authorize differently from the page that links to it. +// +// It reports false once it has written the response, which is a +// redirect to the login page for an unauthenticated request and +// a 404 otherwise. The caller returns without writing more. +func (h *Handlers) ownedWebhook( + w http.ResponseWriter, + r *http.Request, +) (database.Webhook, bool) { + var webhook database.Webhook + + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return database.Webhook{}, false + } + + sourceID := chi.URLParam(r, "sourceID") + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return database.Webhook{}, false + } + + return webhook, true +} + +// deleteChildResource returns a handler that deletes a child +// resource (entrypoint or target) belonging to a webhook. The +// optional afterDelete hook runs with the child's id once the +// delete has removed it, before the redirect, which carries done as +// its notice. +func (h *Handlers) deleteChildResource( + idParam string, + model any, + errMsg string, + afterDelete func(childID string), + done noticeCode, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + childID := chi.URLParam(r, idParam) + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + result := h.db.DB().Where( + "id = ? AND webhook_id = ?", + childID, webhook.ID, + ).Delete(model) + if result.Error != nil { + h.serverError(w, r, errMsg, result.Error) + + return + } + + // Only for a row this webhook really had: the id came from + // the URL and may name another webhook's child. + if afterDelete != nil && result.RowsAffected > 0 { + afterDelete(childID) + } + + http.Redirect( + w, r, + withNotice("/hook/"+webhook.ID, done), + http.StatusSeeOther, + ) + } +} + +// toggleChildResource returns a handler that toggles the active +// state of a child resource belonging to a webhook. toggleFn returns +// the new state, and the redirect carries activated or deactivated as +// its notice to match. +func (h *Handlers) toggleChildResource( + idParam string, + toggleFn func(webhookID, childID string) (bool, error), + errMsg string, + activated, deactivated noticeCode, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + childID := chi.URLParam(r, idParam) + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + active, err := toggleFn(webhook.ID, childID) + if err != nil { + h.serverError(w, r, errMsg, err) + + return + } + + done := deactivated + if active { + done = activated + } + + http.Redirect( + w, r, + withNotice("/hook/"+webhook.ID, done), + http.StatusSeeOther, + ) + } +} + +// getUserID extracts the user ID from the session. +func (h *Handlers) getUserID( + r *http.Request, +) (string, bool) { + sess, err := h.session.Get(r) + if err != nil { + return "", false + } + + if !h.session.IsAuthenticated(sess) { + return "", false + } + + return h.session.GetUserID(sess) +} diff --git a/internal/handlers/source_create_targets_test.go b/internal/handlers/source_create_targets_test.go new file mode 100644 index 0000000..443cded --- /dev/null +++ b/internal/handlers/source_create_targets_test.go @@ -0,0 +1,209 @@ +package handlers_test + +import ( + "errors" + "html" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// submitCreateForm posts the new webhook form and returns the +// recorder. +func submitCreateForm( + env *sourceTestEnv, form url.Values, +) *httptest.ResponseRecorder { + req := formRequest("/hooks/new", env.cookies, form, nil) + w := httptest.NewRecorder() + + env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req) + + return w +} + +// assertNothingCreated checks that the main database holds no webhook, +// entrypoint or target. +func assertNothingCreated(t *testing.T, db *database.Database) { + t.Helper() + + for _, model := range []any{ + &database.Webhook{}, &database.Entrypoint{}, &database.Target{}, + } { + var count int64 + + require.NoError(t, db.DB().Model(model).Count(&count).Error) + assert.Zerof(t, count, "%T rows were created", model) + } +} + +// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new +// webhook form with the HTTP target URL filled in or empty, and with +// the archive checkbox off or on with each pruning choice. The webhook +// gets an HTTP target only for a URL and a database target only for a +// checked archive. The pruning choice is always submitted, as the +// browser submits it while it is hidden, and is ignored when archive +// is off. +func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + // Each value the archive pruning choice submits, after an empty + // one that stands for the archive checkbox left off. + expiries := []string{ + "", "never", "1h", "12h", "24h", "720h", "2160h", "8760h", + } + + for _, httpURL := range []string{"", editOriginalURL} { + for _, expiry := range expiries { + name := "url=" + httpURL + " archive=" + expiry + + t.Run(name, func(t *testing.T) { + t.Parallel() + + form := url.Values{} + form.Set("name", name) + form.Set("http_url", httpURL) + form.Set("archive_expiry", "720h") + + if expiry != "" { + form.Set("archive", "on") + form.Set("archive_expiry", expiry) + } + + w := submitCreateForm(env, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + var webhook database.Webhook + + require.NoError(t, env.db.DB(). + Where("name = ?", name).First(&webhook).Error) + + byType := map[database.TargetType]database.Target{} + for _, target := range targetsForWebhook(t, env.db, webhook.ID) { + byType[target.Type] = target + } + + wantCount := 0 + + if httpURL != "" { + wantCount++ + + assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name) + assert.JSONEq(t, `{"url":"`+httpURL+`"}`, + byType[database.TargetTypeHTTP].Config) + } + + if expiry != "" { + wantCount++ + + assert.Equal(t, "Archive", + byType[database.TargetTypeDatabase].Name) + assert.JSONEq(t, `{"expiry":"`+expiry+`"}`, + byType[database.TargetTypeDatabase].Config) + } + + assert.Len(t, byType, wantCount) + }) + } + } +} + +// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the +// new webhook form for an invalid HTTP target URL and for an invalid +// retention, each with archive on. Nothing is created, and the form +// comes back with the reason and every value entered: name, +// description, retention, URL, the checked archive box and the pruning +// and rotation choices. +func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue( + t *testing.T, +) { + t.Parallel() + + const badURL = "Invalid target URL" + + cases := []struct { + name string + retention string + httpURL string + reason string + }{ + {"blocked url", "7", editBlockedURL, badURL}, + {"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL}, + {"bad retention", "-5", editOriginalURL, "Retention must be"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + form := url.Values{} + form.Set("name", "kept name") + form.Set("description", "kept description") + form.Set("retention_days", tc.retention) + form.Set("http_url", tc.httpURL) + form.Set("archive", "on") + form.Set("archive_expiry", "2160h") + form.Set("archive_rotation", "hourly") + + w := submitCreateForm(env, form) + require.Equal(t, http.StatusBadRequest, w.Code) + + page := w.Body.String() + assert.Contains(t, page, tc.reason) + assert.Contains(t, page, `value="kept name"`) + assert.Contains(t, page, `>kept description`) + assert.Contains(t, page, `value="`+tc.retention+`"`) + assert.Contains(t, page, + `value="`+html.EscapeString(tc.httpURL)+`"`) + assert.Contains(t, page, `name="archive" value="on" checked`) + assert.Contains(t, page, `x-data="collapsible" data-open`) + assert.Contains(t, page, ``) // An unknown type gets the neutral placeholder, never the // stored blob. assert.Contains(t, body, "(unavailable)") assert.NotContains(t, body, "beak") } + +// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook +// page's maximum width at 108rem (1728 px), half again the 72rem of +// max-w-6xl that the webhook list and the event log use, so an +// entrypoint URL fits on one line in a 1920-pixel window; and the +// wrapping of its title row, so the buttons beside the title do not +// push a phone-width window into scrolling sideways. +func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + ) + + app := newTestApp(t, &h, &sess, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + + body := renderSourceDetailPage(t, h, sess, wh.ID) + + assert.Contains( + t, body, + `
`, + ) +} + +// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each +// delete prompt on the webhook page names the webhook, entrypoint or +// target and says what deleting it loses, that the webhook's gives its +// number of stored events (5 received, 2 removed by retention, so 3, +// the statistics pane's "Within retention" figure), and that an +// entrypoint with no description is named by its URL. The template +// writes the slashes after http: as \/, which the browser reads as /. +func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + + webhookDB, err := dbMgr.GetDB(wh.ID) + require.NoError(t, err) + require.NoError(t, database.AddEventTotals( + webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2}, + )) + + unnamed := seedEntrypoint(t, db, wh.ID) + require.NoError(t, db.DB().Omit(clause.Associations).Create( + &database.Entrypoint{ + WebhookID: wh.ID, + Path: "described-" + wh.ID, + Description: "Stripe", + Active: true, + }, + ).Error) + seedTarget(t, db, wh.ID, database.TargetTypeLog) + + body := renderSourceDetailPage(t, h, sess, wh.ID) + + assert.Contains(t, body, + `Delete webhook "delete-me"?\n\n`+ + `This deletes its stored events (3) and their deliveries. `+ + `Any archive files it wrote are kept.`) + assert.Contains(t, body, + `Delete entrypoint "Stripe"?\n\n`+ + `Senders using its URL get an error from now on, `+ + `and the URL cannot be restored.`) + assert.Contains(t, body, + `Delete entrypoint "http:\/\/example.com/h/`+ + unnamed.Path+`"?`) + assert.Contains(t, body, + `Delete target "t-log"?\n\n`+ + `Nothing more is delivered to it. `+ + `Its past deliveries stay in the event log.`) +} + +// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a +// webhook name with quotes, a backslash, a closing script tag and a +// newline reaches its delete prompt escaped for the script, which the +// browser reads back as the name typed: each quote and angle bracket +// as a \u escape, the slash as \/, the newline as \n and the backslash +// doubled. An unescaped newline would break the prompt's script, and +// the form would then submit without asking. +func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + ) + + app := newTestApp(t, &h, &sess, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := &database.Webhook{ + UserID: deleteTestUserID, + Name: "Bob's \"best\" \\ hook\nline two", + } + require.NoError( + t, db.DB().Omit(clause.Associations).Create(wh).Error, + ) + + body := renderSourceDetailPage(t, h, sess, wh.ID) + + assert.Contains(t, body, + "Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+ + "\\u003c\\/script\\u003e\\nline two"?") +} diff --git a/internal/handlers/source_list_test.go b/internal/handlers/source_list_test.go new file mode 100644 index 0000000..29050f6 --- /dev/null +++ b/internal/handlers/source_list_test.go @@ -0,0 +1,467 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/session" +) + +// failedHighlight is how the list marks a number of failed deliveries +// that is not zero. +const failedHighlight = `class="font-medium text-red-600"` + +// listWebhook adds a webhook with the given name, owned by the test +// user. +func listWebhook( + t *testing.T, db *database.Database, name string, +) *database.Webhook { + t.Helper() + + wh := &database.Webhook{UserID: deleteTestUserID, Name: name} + require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) + + return wh +} + +// addEntrypoints adds the given number of entrypoints, all active or +// all inactive, to a webhook and returns their paths. +func addEntrypoints( + t *testing.T, db *database.Database, webhookID string, + count int, active bool, +) []string { + t.Helper() + + paths := make([]string, count) + for i := range paths { + paths[i] = statsEntrypoint(t, db, webhookID, active) + } + + return paths +} + +// addTargets adds the given number of targets, all active or all +// inactive, to a webhook and returns them. +func addTargets( + t *testing.T, db *database.Database, webhookID string, + count int, active bool, +) []*database.Target { + t.Helper() + + targets := make([]*database.Target, count) + for i := range targets { + targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog) + require.NoError(t, db.DB().Model(targets[i]). + Update("active", active).Error) + } + + return targets +} + +// renderWebhookList runs the real webhook list handler as the test user +// and returns the rendered page. +func renderWebhookList( + t *testing.T, h *handlers.Handlers, sess *session.Session, +) string { + t.Helper() + + cookies := authenticatedCookies( + t, sess, deleteTestUserID, deleteTestUsername, + ) + + w := httptest.NewRecorder() + h.HandleSourceList().ServeHTTP( + w, getRequest(t, "/hooks", cookies, nil), + ) + require.Equal(t, http.StatusOK, w.Code) + + return w.Body.String() +} + +// listCard returns one webhook's entry in a rendered webhook list, its +// markup as rendered and its text with the markup taken out and each +// run of space made one space. +func listCard(t *testing.T, page, webhookID string) (string, string) { + t.Helper() + + _, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`) + require.True(t, found, "the list has no entry for %s", webhookID) + + card, _, _ = strings.Cut(card, "") + text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ") + + return card, strings.Join(strings.Fields(text), " ") +} + +// receiveEvents posts the given number of events to an entrypoint +// through the real receiver, and returns the webhook's event database +// and its events, oldest first. +func receiveEvents( + t *testing.T, + h *handlers.Handlers, + dbMgr *database.WebhookDBManager, + webhookID, path string, + count int, +) (*gorm.DB, []database.Event) { + t.Helper() + + router := receiverRouter(h) + + for range count { + require.Equal(t, http.StatusOK, postReceiver(t, router, path)) + } + + webhookDB, err := dbMgr.GetDB(webhookID) + require.NoError(t, err) + + events := listEvents(t, webhookDB) + require.Len(t, events, count) + + return webhookDB, events +} + +// seedFailingWebhook adds a webhook with six entrypoints, two of them +// inactive, and seven targets, five of them inactive. Four events reach +// its two active targets, arriving 31, 5, 4 and 3 hours ago, and its +// event totals row records the last one. Three deliveries failed in the +// last 24 hours, two to the first target and one to the second, one +// failed 30 hours ago, two were delivered, and two are still pending. +// It returns the webhook and when its last event arrived. +func seedFailingWebhook( + t *testing.T, + h *handlers.Handlers, + db *database.Database, + dbMgr *database.WebhookDBManager, +) (*database.Webhook, time.Time) { + t.Helper() + + wh := listWebhook(t, db, "failing") + paths := addEntrypoints(t, db, wh.ID, 4, true) + addEntrypoints(t, db, wh.ID, 2, false) + + active := addTargets(t, db, wh.ID, 2, true) + first, second := active[0], active[1] + + addTargets(t, db, wh.ID, 5, false) + + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4) + now := time.Now() + lastEventAt := now.Add(-3 * time.Hour) + + statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour)) + statsAge(t, webhookDB, events[1].ID, now.Add(-5*time.Hour)) + statsAge(t, webhookDB, events[2].ID, now.Add(-4*time.Hour)) + statsAge(t, webhookDB, events[3].ID, lastEventAt) + require.NoError(t, database.AddEventTotals(webhookDB, + database.EventTotals{LastEventAt: &lastEventAt})) + + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[0].ID, first.ID), + database.DeliveryStatusFailed, now.Add(-30*time.Hour)) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[0].ID, second.ID), + database.DeliveryStatusDelivered, now.Add(-30*time.Hour)) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[1].ID, first.ID), + database.DeliveryStatusFailed, now.Add(-time.Hour)) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[2].ID, first.ID), + database.DeliveryStatusFailed, now.Add(-time.Minute)) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[2].ID, second.ID), + database.DeliveryStatusFailed, now.Add(-time.Minute)) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[3].ID, second.ID), + database.DeliveryStatusDelivered, now.Add(-time.Minute)) + + return wh, lastEventAt +} + +// seedHealthyWebhook adds a webhook with four entrypoints and two +// targets, all active, and three events, arriving 8, 7 and 6 hours ago +// and each delivered to both targets. Its event totals row records the +// last event. It returns the webhook and when its last event arrived. +func seedHealthyWebhook( + t *testing.T, + h *handlers.Handlers, + db *database.Database, + dbMgr *database.WebhookDBManager, +) (*database.Webhook, time.Time) { + t.Helper() + + wh := listWebhook(t, db, "healthy") + paths := addEntrypoints(t, db, wh.ID, 4, true) + targets := addTargets(t, db, wh.ID, 2, true) + + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3) + now := time.Now() + lastEventAt := now.Add(-6 * time.Hour) + + statsAge(t, webhookDB, events[0].ID, now.Add(-8*time.Hour)) + statsAge(t, webhookDB, events[1].ID, now.Add(-7*time.Hour)) + statsAge(t, webhookDB, events[2].ID, lastEventAt) + require.NoError(t, database.AddEventTotals(webhookDB, + database.EventTotals{LastEventAt: &lastEventAt})) + + for _, ev := range events { + for _, target := range targets { + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, ev.ID, target.ID), + database.DeliveryStatusDelivered, now) + } + } + + return wh, lastEventAt +} + +// lastEventText is how the list shows when the last event arrived. +func lastEventText(at time.Time) string { + return at.UTC().Format("2006-01-02 15:04:05 UTC") +} + +// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list +// shows for a webhook with recent failures, a healthy one, a new one +// that has received no event, and one without an event database. +func TestSourceList_ShowsActivityOfEachWebhook(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + failing, failingLastEvent := seedFailingWebhook(t, h, db, dbMgr) + healthy, healthyLastEvent := seedHealthyWebhook(t, h, db, dbMgr) + + // Creating a webhook creates its event database. + fresh := listWebhook(t, db, "fresh") + require.NoError(t, dbMgr.CreateDB(fresh.ID)) + addEntrypoints(t, db, fresh.ID, 2, true) + addTargets(t, db, fresh.ID, 3, true) + + quiet := listWebhook(t, db, "quiet") + addEntrypoints(t, db, quiet.ID, 2, true) + addTargets(t, db, quiet.ID, 3, true) + + page := renderWebhookList(t, h, sess) + + card, text := listCard(t, page, failing.ID) + assert.Contains(t, text, "6 entrypoints, 2 inactive") + assert.Contains(t, text, "7 targets, 5 inactive") + assert.Contains(t, text, "4 events within retention") + assert.Contains(t, text, "Last event "+lastEventText(failingLastEvent)) + assert.Contains(t, card, + failedHighlight+">3 failed deliveries in the last 24 hours<") + + card, text = listCard(t, page, healthy.ID) + assert.Contains(t, text, "4 entrypoints") + assert.Contains(t, text, "2 targets") + assert.Contains(t, text, "3 events within retention") + assert.Contains(t, text, "Last event "+lastEventText(healthyLastEvent)) + assert.Contains(t, text, "0 failed deliveries in the last 24 hours") + assert.NotContains(t, text, "inactive") + assert.NotContains(t, card, failedHighlight) + + card, text = listCard(t, page, fresh.ID) + assert.Contains(t, text, "2 entrypoints") + assert.Contains(t, text, "3 targets") + assert.Contains(t, text, "0 events within retention") + assert.Contains(t, text, "No events yet") + assert.Contains(t, text, "0 failed deliveries in the last 24 hours") + assert.NotContains(t, card, failedHighlight) + + card, text = listCard(t, page, quiet.ID) + assert.Contains(t, text, "2 entrypoints") + assert.Contains(t, text, "3 targets") + assert.Contains(t, text, "0 events within retention") + assert.Contains(t, text, "No events yet") + assert.Contains(t, text, "0 failed deliveries in the last 24 hours") + assert.NotContains(t, card, failedHighlight) + assert.False(t, dbMgr.DBExists(quiet.ID), + "showing the list must not create an event database") +} + +// TestSourceList_CountsOnlyEventsWithinRetention checks that once +// retention has removed one of a webhook's three events, the list +// counts the two still stored. +func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + log *logger.Logger + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr, &log) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := &database.Webhook{ + UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14, + } + require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) + + paths := addEntrypoints(t, db, wh.ID, 3, true) + addTargets(t, db, wh.ID, 4, true) + + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3) + + statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour)) + statsPrune(t, db, dbMgr, log, webhookDB) + require.Len(t, listEvents(t, webhookDB), 2) + + _, text := listCard(t, renderWebhookList(t, h, sess), wh.ID) + assert.Contains(t, text, "3 entrypoints") + assert.Contains(t, text, "4 targets") + assert.Contains(t, text, "2 events within retention") +} + +// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once +// retention has removed every event of a webhook, the list still shows +// when the last one arrived rather than "No events yet". +func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + log *logger.Logger + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr, &log) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := &database.Webhook{ + UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1, + } + require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) + + paths := addEntrypoints(t, db, wh.ID, 2, true) + addTargets(t, db, wh.ID, 3, true) + + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1) + lastEventAt := time.Now().Add(-50 * time.Hour) + + statsAge(t, webhookDB, events[0].ID, lastEventAt) + require.NoError(t, database.AddEventTotals(webhookDB, + database.EventTotals{LastEventAt: &lastEventAt})) + statsPrune(t, db, dbMgr, log, webhookDB) + require.Empty(t, listEvents(t, webhookDB)) + + _, text := listCard(t, renderWebhookList(t, h, sess), wh.ID) + assert.Contains(t, text, "0 events within retention") + assert.Contains(t, text, "Last event "+lastEventText(lastEventAt)) + assert.NotContains(t, text, "No events yet") +} + +// TestSourceList_CountsOfOneInSingular checks that a webhook with one +// entrypoint, one target, one event within retention and one failed +// delivery in the last 24 hours has each written in the singular. +func TestSourceList_CountsOfOneInSingular(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := listWebhook(t, db, "single") + paths := addEntrypoints(t, db, wh.ID, 1, true) + targets := addTargets(t, db, wh.ID, 1, true) + + webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1) + now := time.Now() + lastEventAt := now.Add(-9 * time.Hour) + + statsAge(t, webhookDB, events[0].ID, lastEventAt) + require.NoError(t, database.AddEventTotals(webhookDB, + database.EventTotals{LastEventAt: &lastEventAt})) + statsFinish(t, webhookDB, + statsDelivery(t, webhookDB, events[0].ID, targets[0].ID), + database.DeliveryStatusFailed, now.Add(-time.Hour)) + + card, text := listCard(t, renderWebhookList(t, h, sess), wh.ID) + assert.Contains(t, card, ">1 entrypoint<") + assert.Contains(t, card, ">1 target<") + assert.Contains(t, card, ">1 event within retention<") + assert.Contains(t, text, "Last event "+lastEventText(lastEventAt)) + assert.Contains(t, card, + failedHighlight+">1 failed delivery in the last 24 hours<") +} + +// TestSourceList_UnreadableEventDatabase checks that a webhook whose +// event database cannot be read says so in its entry instead of +// showing zeros, and that the rest of the list is still shown. +func TestSourceList_UnreadableEventDatabase(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + broken := listWebhook(t, db, "broken") + addEntrypoints(t, db, broken.ID, 2, true) + addTargets(t, db, broken.ID, 3, true) + + brokenDB, err := dbMgr.GetDB(broken.ID) + require.NoError(t, err) + require.NoError(t, + brokenDB.Migrator().DropTable(&database.EventTotals{})) + + quiet := listWebhook(t, db, "quiet") + addEntrypoints(t, db, quiet.ID, 2, true) + addTargets(t, db, quiet.ID, 3, true) + + page := renderWebhookList(t, h, sess) + + _, text := listCard(t, page, broken.ID) + assert.Contains(t, text, "2 entrypoints") + assert.Contains(t, text, "3 targets") + assert.Contains(t, text, "The event figures could not be read.") + assert.NotContains(t, text, "events") + assert.NotContains(t, text, "failed") + + _, text = listCard(t, page, quiet.ID) + assert.Contains(t, text, "No events yet") +} diff --git a/internal/handlers/source_logs_deleted_target_test.go b/internal/handlers/source_logs_deleted_target_test.go index 31c7a1f..e38da68 100644 --- a/internal/handlers/source_logs_deleted_target_test.go +++ b/internal/handlers/source_logs_deleted_target_test.go @@ -28,7 +28,7 @@ func deleteTargetThroughHandler( t.Helper() req := postRequest( - "/source/"+webhookID+"/targets/"+targetID+"/delete", + "/hook/"+webhookID+"/targets/"+targetID+"/delete", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ), @@ -94,6 +94,44 @@ func TestHandleSourceLogs_NamesDeletedTarget(t *testing.T) { ) } +// TestHandleSourceLogs_OffersNoReplayForDeletedTarget proves a +// finished delivery offers Replay while its target lives and not +// once the target is deleted. A replay to a deleted target is always +// refused, and recreating the target makes a new one that the old +// delivery does not name. +func TestHandleSourceLogs_OffersNoReplayForDeletedTarget(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + tgt := seedTarget(t, db, wh.ID, database.TargetTypeLog) + + _, failed := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + replayForm := `action="/hook/` + wh.ID + `/deliveries/` + + failed.ID + `/replay"` + + before := renderSourceLogsPage(t, h, sess, wh.ID) + assert.Contains(t, before, replayForm) + + deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID) + + after := renderSourceLogsPage(t, h, sess, wh.ID) + assert.NotContains(t, after, replayForm) + assert.NotContains(t, after, ">Replay<") + assert.Contains(t, after, tgt.Name+deletedMarker) +} + // TestHandleSourceLogs_MasksDeletedTargetConfig proves that // naming a deleted target does not widen what the page shows of // it: its stored configuration stays masked by exactly the rules diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index 90f5a60..0cbe5fd 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -2,9 +2,13 @@ package handlers_test import ( "context" + "fmt" "net/http" "net/http/httptest" + "slices" + "strings" "testing" + "time" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" @@ -84,7 +88,7 @@ func renderSourceLogsPageWithQuery( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+webhookID+"/logs"+query, + "/hook/"+webhookID+"/events"+query, nil, ) @@ -153,3 +157,201 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) { assert.Contains(t, body, tgt.Name) assert.Contains(t, body, "delivered") } + +// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log +// holds the 50 newest events, newest first, and not one more, and says +// how many events there are in all. +func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + f.event( + t, fmt.Sprintf("application/x-log-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + } + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 50, strings.Count(body, `role="button"`)) + assert.NotContains(t, body, "application/x-log-00") + assert.Contains(t, body, "application/x-log-01") + assert.Less( + t, + strings.Index(body, "application/x-log-50"), + strings.Index(body, "application/x-log-49"), + ) + assert.Contains(t, body, "50 most recent of 51 events") +} + +// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the +// Failed list holds exactly the events with a failed delivery, the +// Pending list exactly those with a delivery pending or retrying, each +// once, and any other show value every event; that each link, and the +// line beside the heading, counts the events its list holds; and that +// the shown link is marked. +func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + now := time.Now() + + const ( + failed = database.DeliveryStatusFailed + delivered = database.DeliveryStatusDelivered + pending = database.DeliveryStatusPending + retrying = database.DeliveryStatusRetrying + ) + + // Each event is named by its content type. The first failed and + // was then replayed and delivered. The second failed, and so did + // its replay, and the fifth has one delivery pending and another + // retrying: each must still be listed and counted once. + events := []struct { + contentType string + deliveries []database.DeliveryStatus + }{ + {"application/x-failed", []database.DeliveryStatus{failed, delivered}}, + {"application/x-failed-twice", []database.DeliveryStatus{failed, failed}}, + {"application/x-pending", []database.DeliveryStatus{pending}}, + {"application/x-retrying", []database.DeliveryStatus{retrying}}, + {"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}}, + {"application/x-delivered", []database.DeliveryStatus{delivered}}, + {"application/x-no-delivery", nil}, + } + + all := make([]string, len(events)) + + for i, e := range events { + event := f.event( + t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second), + ) + + for _, status := range e.deliveries { + f.delivery(t, event, target.ID, status) + } + + all[i] = e.contentType + } + + for _, tc := range []struct { + query string + current string + heading string + listed []string + }{ + {"", "All", "7 total events", all}, + {"?show=failed", "Failed (2)", "2 events with a failed delivery", + []string{"application/x-failed", "application/x-failed-twice"}}, + {"?show=pending", "Pending (3)", + "3 events with a delivery pending or retrying", []string{ + "application/x-pending", "application/x-retrying", + "application/x-pending-retrying", + }}, + {"?show=unknown", "All", "7 total events", all}, + } { + body := renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, tc.query, + ) + + // One row per listed event, so with each listed event shown + // no event is listed twice. + assert.Equal(t, len(tc.listed), + strings.Count(body, `role="button"`), tc.query) + + for _, contentType := range all { + assert.Equal(t, + slices.Contains(tc.listed, contentType), + strings.Contains(body, ">"+contentType+"<"), + tc.query+" "+contentType) + } + + assert.Contains(t, body, ">"+tc.heading+"<", tc.query) + assert.Contains(t, body, "Failed (2)", tc.query) + assert.Contains(t, body, "Pending (3)", tc.query) + assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query) + assert.Contains(t, body, + `aria-current="page">`+tc.current+"", tc.query) + } +} + +// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered +// list holds the 50 newest matching events, as the full log does, +// while its link and heading count every matching event. +func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + event := f.event( + t, fmt.Sprintf("application/x-failed-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + f.delivery(t, event, target.ID, database.DeliveryStatusFailed) + } + + // The newest event has no failed delivery. + f.event(t, "application/x-no-delivery", "{}", time.Now()) + + body := renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=failed", + ) + + assert.Equal(t, 50, strings.Count(body, `role="button"`)) + assert.NotContains(t, body, "application/x-failed-00") + assert.NotContains(t, body, "application/x-no-delivery") + assert.Contains(t, body, "Failed (51)") + assert.Contains(t, body, + "50 most recent of 51 events with a failed delivery") +} + +// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list +// says that no event matches rather than that none was recorded. +func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + + f.delivery( + t, f.event(t, contentTypeJSON, "{}", time.Now()), + target.ID, database.DeliveryStatusDelivered, + ) + + assert.Contains(t, renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=failed", + ), "No event has a failed delivery.") + assert.Contains(t, renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=pending", + ), "No event has a delivery pending or retrying.") +} + +// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the +// events in the log only the newest starts expanded. +func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + now := time.Now() + + f.event(t, "application/x-older", "{}", now.Add(-time.Minute)) + f.event(t, "application/x-newer", "{}", now) + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 1, strings.Count(body, " data-open>")) + + open := strings.Index(body, " data-open>") + newer := strings.Index(body, "application/x-newer") + older := strings.Index(body, "application/x-older") + + assert.Less(t, open, newer, "the newest event is not the open one") + assert.Less(t, newer, older) +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go deleted file mode 100644 index 4abb7fc..0000000 --- a/internal/handlers/source_management.go +++ /dev/null @@ -1,1827 +0,0 @@ -package handlers - -import ( - "encoding/json" - "errors" - "net/http" - "slices" - "strconv" - "strings" - - "github.com/go-chi/chi" - "github.com/google/uuid" - "gorm.io/gorm" - "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/reqtls" -) - -// WebhookListItem holds data for the webhook list view. -type WebhookListItem struct { - database.Webhook - - EntrypointCount int64 - TargetCount int64 - EventCount int64 -} - -// errMissingURL signals that a required URL was not provided. -var errMissingURL = errors.New("missing URL") - -// errInvalidRetention signals a retention_days form value that is not -// a non-negative whole number. -var errInvalidRetention = errors.New("invalid retention days") - -// errRetentionTooLarge signals a retention_days form value that is a -// whole number but larger than the reaper's cutoff arithmetic can -// represent. It is distinguished from errInvalidRetention so the form -// can tell the user the actual ceiling instead of implying their input -// was not a number. -var errRetentionTooLarge = errors.New("retention days out of range") - -// retentionErrorMessage returns the message the create and edit forms -// show the user for a rejected retention_days value. Any error other -// than errRetentionTooLarge falls back to the generic wording, so an -// unrecognised parse failure still produces a sensible 400 rather than -// an empty alert. -func retentionErrorMessage(err error) string { - if errors.Is(err, errRetentionTooLarge) { - return "Retention must be at most " + - strconv.Itoa(database.MaxFiniteRetentionDays) + - " days, or 0 to retain events forever." - } - - return "Retention must be a whole number of days, or 0 to " + - "retain events forever." -} - -// parseRetentionDays interprets a retention_days form value. -// -// An empty value yields fallback, which lets the create path apply the -// default and the edit path leave the stored value unchanged. A value -// of 0 is returned as 0 and is rewritten to the retain-forever -// sentinel by database.Webhook's BeforeSave hook. Anything unparseable -// or negative is an error rather than a silently substituted default. -// -// The upper bound is not cosmetic. The reaper computes its cutoff as a -// time.Duration, an int64 nanosecond count, so a day count above -// database.MaxFiniteRetentionDays overflows, puts the cutoff in the -// future, and deletes every event the webhook has. A finite value -// above that ceiling is therefore a 400. -// -// A value at or above the retain-forever sentinel is not out of range: -// it is what the edit form pre-fills for a retain-forever webhook, so -// submitting the form back unchanged has to keep meaning "forever" -// rather than being rejected. -func parseRetentionDays(raw string, fallback int) (int, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback, nil - } - - v, err := strconv.Atoi(raw) - if err != nil || v < 0 { - return 0, errInvalidRetention - } - - if v >= database.RetentionForeverDays { - return database.RetentionForeverDays, nil - } - - if v > database.MaxFiniteRetentionDays { - return 0, errRetentionTooLarge - } - - return v, nil -} - -// DeliveryView is the display-safe projection of a delivery -// for the event log page. Its target is a TargetView, so the -// stored configuration blob — which holds the target's -// credential — has no path to the template. -type DeliveryView struct { - ID string - Status database.DeliveryStatus - Target delivery.TargetView - - // Results is this delivery's attempts in attempt order, - // bounded by maxRenderedAttempts. Without them a failure - // renders as the status word alone and says nothing about - // why. - Results []DeliveryResultView - - // AttemptCount is how many attempts were recorded, which - // is more than len(Results) once the middle was dropped. - AttemptCount int - - // AttemptsOmitted is how many attempts were dropped from - // the middle of Results. The page must show it, or the - // bound would hide history rather than fold it. - AttemptsOmitted int -} - -// eventLogTarget is what the event log needs to know about -// one target: the display-safe view its template renders, and -// the redactor that keeps that target's own credential out of -// the text its remote peer chose. The two are kept together -// so a caller cannot pick up one without the other, and apart -// from TargetView so the secrets never reach a template. -type eventLogTarget struct { - View delivery.TargetView - Redactor delivery.Redactor -} - -// HandleSourceList shows a list of user's webhooks. -func (h *Handlers) HandleSourceList() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - var webhooks []database.Webhook - - err := h.db.DB().Where( - "user_id = ?", userID, - ).Order("created_at DESC").Find(&webhooks).Error - if err != nil { - h.log.Error( - "failed to list webhooks", "error", err, - ) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) - - return - } - - items := h.buildWebhookListItems(webhooks) - - data := map[string]any{ - "Webhooks": items, - } - - h.renderTemplate(w, r, "sources_list.html", data) - } -} - -// buildWebhookListItems builds list items with counts. -func (h *Handlers) buildWebhookListItems( - webhooks []database.Webhook, -) []WebhookListItem { - items := make([]WebhookListItem, len(webhooks)) - - for i := range webhooks { - items[i].Webhook = webhooks[i] - - h.db.DB().Model(&database.Entrypoint{}).Where( - "webhook_id = ?", webhooks[i].ID, - ).Count(&items[i].EntrypointCount) - - h.db.DB().Model(&database.Target{}).Where( - "webhook_id = ?", webhooks[i].ID, - ).Count(&items[i].TargetCount) - - if h.dbMgr.DBExists(webhooks[i].ID) { - webhookDB, err := h.dbMgr.GetDB( - webhooks[i].ID, - ) - if err == nil { - webhookDB.Model( - &database.Event{}, - ).Count(&items[i].EventCount) - } - } - } - - return items -} - -// HandleSourceCreate shows the form to create a new webhook. -func (h *Handlers) HandleSourceCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - h.renderTemplate( - w, r, "sources_new.html", - newSourceFormData("", "", ""), - ) - } -} - -// newSourceFormData builds the template data for the webhook creation -// form. -// -// It carries the retention default so the pre-filled value comes from -// database.DefaultRetentionDays rather than being a third hardcoded -// copy of the same policy, and it carries the submitted name and -// description so that re-rendering the form after a validation failure -// gives the user their input back instead of a blank form. The edit -// form already behaves that way; create now matches it. -func newSourceFormData( - errMsg, name, description string, -) map[string]any { - return map[string]any{ - tmplKeyError: errMsg, - "Name": name, - "Description": description, - "DefaultRetentionDays": database.DefaultRetentionDays, - } -} - -// HandleSourceCreateSubmit handles the webhook creation form -// submission. -func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err := r.ParseForm() - if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) - - return - } - - name := r.PostFormValue("name") - description := r.PostFormValue("description") - retentionStr := r.PostFormValue("retention_days") - - if name == "" { - w.WriteHeader(http.StatusBadRequest) - h.renderTemplate( - w, r, "sources_new.html", - newSourceFormData( - "Name is required", name, description, - ), - ) - - return - } - - retentionDays, retErr := parseRetentionDays( - retentionStr, database.DefaultRetentionDays, - ) - if retErr != nil { - w.WriteHeader(http.StatusBadRequest) - h.renderTemplate( - w, r, "sources_new.html", - newSourceFormData( - retentionErrorMessage(retErr), - name, description, - ), - ) - - return - } - - h.createWebhookWithEntrypoint( - w, r, userID, name, description, retentionDays, - ) - } -} - -// createWebhookWithEntrypoint creates a webhook and its default -// entrypoint in a transaction. -func (h *Handlers) createWebhookWithEntrypoint( - w http.ResponseWriter, - r *http.Request, - userID, name, description string, - retentionDays int, -) { - webhook := &database.Webhook{ - UserID: userID, - Name: name, - Description: description, - RetentionDays: retentionDays, - } - - err := h.commitWebhook(webhook) - if err != nil { - h.serverError(w, "failed to create webhook", err) - - return - } - - err = h.dbMgr.CreateDB(webhook.ID) - if err != nil { - h.log.Error( - "failed to create webhook event database", - "webhook_id", webhook.ID, "error", err, - ) - } - - h.log.Info("webhook created", - "webhook_id", webhook.ID, - "name", name, "user_id", userID, - ) - - http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, - ) -} - -// commitWebhook creates a webhook and default entrypoint in -// a transaction. Returns an error on failure (rolls back). -func (h *Handlers) commitWebhook( - webhook *database.Webhook, -) error { - tx := h.db.DB().Begin() - if tx.Error != nil { - return tx.Error - } - - err := tx.Create(webhook).Error - if err != nil { - tx.Rollback() - - return err - } - - entrypoint := &database.Entrypoint{ - WebhookID: webhook.ID, - Path: uuid.New().String(), - Description: "Default entrypoint", - Active: true, - } - - err = tx.Create(entrypoint).Error - if err != nil { - tx.Rollback() - - return err - } - - return tx.Commit().Error -} - -// HandleSourceDetail shows details for a specific webhook. -func (h *Handlers) HandleSourceDetail() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - h.renderSourceDetail(w, r, webhook) - } -} - -// renderSourceDetail loads and renders a source detail page. -func (h *Handlers) renderSourceDetail( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, -) { - var entrypoints []database.Entrypoint - - h.db.DB().Where( - "webhook_id = ?", webhook.ID, - ).Find(&entrypoints) - - var targets []database.Target - - h.db.DB().Where( - "webhook_id = ?", webhook.ID, - ).Find(&targets) - - var events []database.Event - - if h.dbMgr.DBExists(webhook.ID) { - webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID) - if dbErr == nil { - webhookDB.Where( - "webhook_id = ?", webhook.ID, - ).Order("created_at DESC").Limit( - recentEventLimit, - ).Find(&events) - } - } - - scheme := "http" - if reqtls.IsTLS(r) { - scheme = "https" - } - - // The host is the client's Host header, unvalidated. It is - // inert only because source_detail.html renders BaseURL as - // text inside a element; putting it in an href or any - // other URL context needs it constrained first. - baseURL := scheme + "://" + r.Host - - // The template calls Webhook methods, which take pointer - // receivers; html/template cannot address a value stored in a map. - data := map[string]any{ - tmplKeyWebhook: &webhook, - // Targets are projected to a display-safe view: a - // target's stored config blob holds a credential, and it - // must never reach a template. - "Entrypoints": NewEntrypointViews(entrypoints), - "Targets": delivery.NewTargetViews(targets), - "Events": events, - "BaseURL": baseURL, - } - - h.renderTemplate(w, r, "source_detail.html", data) -} - -// HandleSourceEdit shows the form to edit a webhook. -func (h *Handlers) HandleSourceEdit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - data := map[string]any{ - tmplKeyWebhook: &webhook, - tmplKeyError: "", - } - - h.renderTemplate(w, r, "source_edit.html", data) - } -} - -// HandleSourceEditSubmit handles the webhook edit form -// submission. -func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) - - return - } - - h.applyWebhookEdit(w, r, &webhook) - } -} - -// applyWebhookEdit validates and saves webhook edits. -func (h *Handlers) applyWebhookEdit( - w http.ResponseWriter, - r *http.Request, - webhook *database.Webhook, -) { - // The body size cap is enforced by the MaxBodySize middleware, - // which runs before CSRF parses the form. - name := r.PostFormValue("name") - if name == "" { - data := map[string]any{ - tmplKeyWebhook: webhook, - tmplKeyError: "Name is required", - } - - w.WriteHeader(http.StatusBadRequest) - h.renderTemplate(w, r, "source_edit.html", data) - - return - } - - webhook.Name = name - webhook.Description = r.PostFormValue("description") - - // An empty field falls back to the stored value, so submitting the - // form without touching retention leaves the policy alone. - retentionDays, retErr := parseRetentionDays( - r.PostFormValue("retention_days"), webhook.RetentionDays, - ) - if retErr != nil { - data := map[string]any{ - tmplKeyWebhook: webhook, - tmplKeyError: retentionErrorMessage(retErr), - } - - w.WriteHeader(http.StatusBadRequest) - h.renderTemplate(w, r, "source_edit.html", data) - - return - } - - webhook.RetentionDays = retentionDays - - err := h.db.DB().Save(webhook).Error - if err != nil { - h.serverError(w, "failed to update webhook", err) - - return - } - - http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, - ) -} - -// HandleSourceDelete handles webhook deletion. -func (h *Handlers) HandleSourceDelete() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - h.deleteWebhookResources(w, r, webhook, userID) - } -} - -// deleteWebhookResources soft-deletes config and hard-deletes -// the per-webhook event database. -func (h *Handlers) deleteWebhookResources( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, - userID string, -) { - // The configuration delete commits before the event database - // is touched. No transaction spans the main database and the - // filesystem, so one side has to go first: committing the - // configuration first means a later failure leaves an unused - // event database file on disk, while removing the event - // database first would mean a failed commit destroys the - // history of a webhook that still exists. A leftover file can - // be removed by hand; deleted history cannot be recovered. - err := h.commitWebhookDeletion(&webhook) - if err != nil { - h.serverError(w, "failed to delete webhook", err) - - return - } - - h.log.Info( - "webhook deleted", - "webhook_id", webhook.ID, - "user_id", userID, - ) - - // Release the delivery engine's per-webhook archiving state - // so a deleted webhook's archive writer (and any handle open - // within its debounce window) does not linger for the - // process lifetime. The archive file itself is deliberately - // left on disk; see evictArchiveWriter. - h.evictArchiveWriter(webhook.ID) - - err = h.dbMgr.DeleteDB(webhook.ID) - if err != nil { - // The configuration is committed, so the webhook is gone, - // but its event database file is still on disk with - // nothing referencing it. Report the failure rather than - // redirecting as though everything succeeded: the file - // needs removing by hand, and the logged error names it. - h.serverError( - w, "failed to delete webhook event database", err, - ) - - return - } - - http.Redirect(w, r, "/sources", http.StatusSeeOther) -} - -// commitWebhookDeletion soft-deletes a webhook's entrypoints, -// targets and the webhook row in one transaction. Every -// statement is checked and any failure rolls the whole -// transaction back, so a caller that gets an error knows the -// configuration is untouched and the event database must be -// left alone. -func (h *Handlers) commitWebhookDeletion( - webhook *database.Webhook, -) error { - tx := h.db.DB().Begin() - if tx.Error != nil { - return tx.Error - } - - err := tx.Where( - "webhook_id = ?", webhook.ID, - ).Delete(&database.Entrypoint{}).Error - if err != nil { - tx.Rollback() - - return err - } - - err = tx.Where( - "webhook_id = ?", webhook.ID, - ).Delete(&database.Target{}).Error - if err != nil { - tx.Rollback() - - return err - } - - err = tx.Delete(webhook).Error - if err != nil { - tx.Rollback() - - return err - } - - return tx.Commit().Error -} - -// evictArchiveWriter asks the delivery engine to drop its -// cached archive writer for a webhook, closing the archive file -// handle. -// -// The archive database file is NOT deleted. Unlike the event -// database — which is per-webhook working storage and is -// hard-deleted with the webhook — an archive is explicitly -// long-term storage that an operator may want to keep or move -// away for offline retention. Destroying it as a side effect of -// deleting a webhook would be a surprising and unrecoverable -// data loss, so the file is left for the operator to handle. -func (h *Handlers) evictArchiveWriter(webhookID string) { - if h.evictor == nil { - return - } - - h.evictor.EvictWebhook(webhookID) -} - -// evictArchiveWriterIfUnused releases a webhook's archive -// writer once the webhook has no database target left to feed -// it. -// -// It is called after any child resource of a webhook is -// deleted, and is correct without knowing which kind was: it -// evicts only when no database target remains, so deleting one -// of several database targets — or deleting an unrelated -// target type — leaves a still-needed writer alone. When no -// database target ever existed there is no writer and eviction -// is a no-op. Soft-deleted targets are excluded by GORM's -// default scope, so the row just deleted is not counted. -func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) { - var remaining int64 - - err := h.db.DB(). - Model(&database.Target{}). - Where( - "webhook_id = ? AND type = ?", - webhookID, database.TargetTypeDatabase, - ). - Count(&remaining).Error - if err != nil { - h.log.Error( - "failed to count remaining database targets", - "webhook_id", webhookID, - "error", err, - ) - - return - } - - if remaining > 0 { - return - } - - h.evictArchiveWriter(webhookID) -} - -// ownedWebhook resolves the request's sourceID parameter to a -// webhook the session's user owns. -// -// Ownership and existence are decided by one query, so a -// webhook belonging to another user is indistinguishable from -// one that does not exist: both are a 404, and neither confirms -// the id. Callers that reach further into a webhook's data — -// the event log page and the event body download — share this -// one check rather than restating it, so the download cannot -// come to authorize differently from the page that links to it. -// -// It reports false once it has written the response, which is a -// redirect to the login page for an unauthenticated request and -// a 404 otherwise. The caller returns without writing more. -func (h *Handlers) ownedWebhook( - w http.ResponseWriter, - r *http.Request, -) (database.Webhook, bool) { - var webhook database.Webhook - - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return database.Webhook{}, false - } - - sourceID := chi.URLParam(r, "sourceID") - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return database.Webhook{}, false - } - - return webhook, true -} - -// HandleSourceLogs shows the request/response logs for a -// webhook. -func (h *Handlers) HandleSourceLogs() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - webhook, ok := h.ownedWebhook(w, r) - if !ok { - return - } - - targets, err := h.loadTargetMap(webhook.ID) - if err != nil { - // Without the map every delivery renders through a - // zero redactor, so failing the page is the only - // safe answer. - h.serverError(w, "failed to load targets", err) - - return - } - - page := h.parsePage(r) - - evts, total, ok := h.loadEventsWithDeliveries( - w, webhook, targets, page, - ) - if !ok { - return - } - - totalPages := int(total) / paginationPerPage - if int(total)%paginationPerPage != 0 { - totalPages++ - } - - // The banner a replay or resubmit POST redirected back - // with. The message comes from a fixed set keyed by the - // outcome code, never from the query string itself. - replayMsg, replayOK := replayOutcome( - r.URL.Query().Get(replayOutcomeParam), - ) - - resubmitMsg, resubmitOK := resubmitOutcome( - r.URL.Query().Get(resubmitOutcomeParam), - ) - - data := map[string]any{ - tmplKeyWebhook: &webhook, - "Events": evts, - "ReplayMessage": replayMsg, - "ReplayQueued": replayOK, - "ResubmitMessage": resubmitMsg, - "ResubmitQueued": resubmitOK, - "Page": page, - "TotalPages": totalPages, - "TotalEvents": total, - "HasPrev": page > 1, - "HasNext": page < totalPages, - "PrevPage": page - 1, - "NextPage": page + 1, - } - - h.renderTemplate(w, r, "source_logs.html", data) - } -} - -// loadTargetMap loads targets into a map of display-safe -// views keyed by target ID, each paired with its redactor. -// The projection happens here so that no caller can hand a -// raw target, configuration blob and all, to a template: the -// raw rows do not leave this function. -// -// The load is Unscoped because deleting a target only soft -// deletes the row while its deliveries survive in the -// per-webhook database. Both halves of the map need those rows: -// a scoped load leaves an old delivery with a zero redactor, -// which renders its response bodies unredacted, and with a zero -// view, which renders its target as a blank name. -// -// This map is historical display only. It is built for the event -// log page and reaches nothing but DeliveryView.Target: the -// target list on the source detail page, the edit form and the -// replay path each resolve targets themselves, and a deleted row -// is refused there as before. -func (h *Handlers) loadTargetMap( - webhookID string, -) (map[string]eventLogTarget, error) { - var targets []database.Target - - err := h.db.DB().Unscoped().Where( - "webhook_id = ?", webhookID, - ).Find(&targets).Error - if err != nil { - return nil, err - } - - targetMap := make( - map[string]eventLogTarget, len(targets), - ) - - for i := range targets { - targetMap[targets[i].ID] = eventLogTarget{ - Redactor: delivery.NewRedactor(&targets[i]), - } - } - - // The views come from NewTargetViews rather than being - // rebuilt here, so the masking rules stay in one place and a - // deleted target's configuration is masked by the same code - // that masks a live one's. - for _, v := range delivery.NewTargetViews(targets) { - entry := targetMap[v.ID] - entry.View = v - targetMap[v.ID] = entry - } - - return targetMap, nil -} - -// parsePage extracts a page number from the query string. -func (h *Handlers) parsePage(r *http.Request) int { - return pageOrFirst(r.URL.Query().Get("page")) -} - -// loadEventsWithDeliveries loads paginated events and their -// deliveries from the per-webhook database. Events come back -// as capped projections rather than database.Event rows: see -// eventLogColumns for why the cut happens in SQL. -// -// The bool reports whether the load succeeded. It is false -// once this has answered the request with an error, and the -// caller must then render nothing further. -func (h *Handlers) loadEventsWithDeliveries( - w http.ResponseWriter, - webhook database.Webhook, - targetMap map[string]eventLogTarget, - page int, -) ([]EventLogView, int64, bool) { - var result []EventLogView - - if !h.dbMgr.DBExists(webhook.ID) { - return result, 0, true - } - - webhookDB, err := h.dbMgr.GetDB(webhook.ID) - if err != nil { - h.serverError( - w, "failed to get webhook database", err, - ) - - return nil, 0, false - } - - rows, totalEvents := loadEventLogRows( - webhookDB, webhook.ID, page, - ) - - result = make([]EventLogView, len(rows)) - eventDeliveries := make([][]database.Delivery, len(rows)) - - var deliveryIDs []string - - eventIDs := make([]string, len(rows)) - - for i := range rows { - result[i] = rows[i].view() - eventIDs[i] = rows[i].ID - - webhookDB.Where( - "event_id = ?", rows[i].ID, - ).Find(&eventDeliveries[i]) - - for j := range eventDeliveries[i] { - deliveryIDs = append( - deliveryIDs, eventDeliveries[i][j].ID, - ) - } - } - - attempts, err := h.loadDeliveryResults( - webhookDB, deliveryIDs, - ) - if err != nil { - h.serverError( - w, "failed to load delivery attempts", err, - ) - - return nil, 0, false - } - - resubmits, err := resubmitCounts(webhookDB, eventIDs) - if err != nil { - h.serverError( - w, "failed to count event resubmissions", err, - ) - - return nil, 0, false - } - - for i := range rows { - result[i].Deliveries = newDeliveryViews( - eventDeliveries[i], targetMap, attempts, - ) - result[i].ResubmitCount = resubmits[rows[i].ID] - } - - return result, totalEvents, true -} - -// loadEventLogRows reads one page of the event log projection, newest -// first, and the total number of events the pager counts against. -func loadEventLogRows( - webhookDB *gorm.DB, webhookID string, page int, -) ([]eventLogRow, int64) { - var totalEvents int64 - - webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ).Count(&totalEvents) - - var rows []eventLogRow - - webhookDB.Model(&database.Event{}).Select( - eventLogColumns, maxRenderedBodyBytes, - ).Where( - "webhook_id = ?", webhookID, - ).Order("created_at DESC").Offset( - (page - 1) * paginationPerPage, - ).Limit(paginationPerPage).Find(&rows) - - return rows, totalEvents -} - -// resubmitCounts reports, for each of the page's events, how many -// events have been resubmitted from it. -// -// One grouped query covers the page rather than one query per event. -// A page holds paginationPerPage ids, far below SQLite's bound -// parameter ceiling, so it needs no chunking as the delivery result -// load does. -func resubmitCounts( - webhookDB *gorm.DB, eventIDs []string, -) (map[string]int, error) { - counts := make(map[string]int, len(eventIDs)) - - if len(eventIDs) == 0 { - return counts, nil - } - - var rows []struct { - ResubmittedFromID string - Total int - } - - err := webhookDB.Model(&database.Event{}). - Select("resubmitted_from_id, count(*) AS total"). - Where("resubmitted_from_id IN ?", eventIDs). - Group("resubmitted_from_id"). - Find(&rows).Error - if err != nil { - return nil, err - } - - for _, row := range rows { - counts[row.ResubmittedFromID] = row.Total - } - - return counts, nil -} - -// deliveryIDChunkSize bounds how many delivery IDs go into one -// IN clause. SQLite refuses a statement carrying more than -// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a -// page holds one delivery per target per event, so a webhook -// with enough targets would turn the whole query into an error -// and the page into zero attempts. -const deliveryIDChunkSize = 500 - -// loadDeliveryResults loads the recorded attempts for the -// page's deliveries, keyed by delivery ID. -// -// Each response body is cut by SQLite rather than in Go, for -// the reason deliveryResultColumns gives. How many attempts a -// delivery has is the target's MaxRetries, which the -// authenticated operator sets; how many of them reach the page -// is bounded again by maxRenderedAttempts. -func (h *Handlers) loadDeliveryResults( - webhookDB *gorm.DB, - deliveryIDs []string, -) (map[string][]deliveryResultRow, error) { - byDelivery := make(map[string][]deliveryResultRow) - - for chunk := range slices.Chunk( - deliveryIDs, deliveryIDChunkSize, - ) { - var rows []deliveryResultRow - - err := webhookDB.Model( - &database.DeliveryResult{}, - ).Select( - deliveryResultColumns, maxRenderedResponseBytes, - ).Where( - "delivery_id IN ?", chunk, - ).Order("attempt_num ASC").Find(&rows).Error - if err != nil { - // Returning what was loaded so far renders the - // deliveries in the failed chunk as never having run, - // which is indistinguishable from ones that really - // never ran. The page fails instead. - return nil, err - } - - for i := range rows { - byDelivery[rows[i].DeliveryID] = append( - byDelivery[rows[i].DeliveryID], rows[i], - ) - } - } - - return byDelivery, nil -} - -// newDeliveryViews projects deliveries for rendering, -// resolving each one's target to its display-safe view and -// each one's attempts through that target's redactor. -func newDeliveryViews( - deliveries []database.Delivery, - targetMap map[string]eventLogTarget, - attempts map[string][]deliveryResultRow, -) []DeliveryView { - views := make([]DeliveryView, len(deliveries)) - - for i := range deliveries { - target := targetMap[deliveries[i].TargetID] - rows := attempts[deliveries[i].ID] - - results, omitted := renderedAttempts( - rows, target.Redactor, - ) - - views[i] = DeliveryView{ - ID: deliveries[i].ID, - Status: deliveries[i].Status, - Target: target.View, - Results: results, - AttemptCount: len(rows), - AttemptsOmitted: omitted, - } - } - - return views -} - -// maxRenderedAttempts bounds how many of one delivery's -// attempts the page renders. Past it the middle is dropped and -// counted, keeping the first attempts and the last ones: how -// the delivery started failing and how it ended are what a -// reader needs, and the count says plainly that the rest was -// dropped rather than never recorded. -const ( - renderedAttemptsHead = 10 - renderedAttemptsTail = 10 - maxRenderedAttempts = renderedAttemptsHead + - renderedAttemptsTail -) - -// renderedAttempts projects a delivery's attempts through the -// target's redactor, at most maxRenderedAttempts of them, and -// reports how many it dropped. -func renderedAttempts( - rows []deliveryResultRow, - redactor delivery.Redactor, -) ([]DeliveryResultView, int) { - omitted := 0 - - if len(rows) > maxRenderedAttempts { - omitted = len(rows) - maxRenderedAttempts - - kept := make( - []deliveryResultRow, 0, maxRenderedAttempts, - ) - kept = append(kept, rows[:renderedAttemptsHead]...) - kept = append( - kept, rows[len(rows)-renderedAttemptsTail:]..., - ) - rows = kept - } - - views := make([]DeliveryResultView, len(rows)) - for i := range rows { - views[i] = rows[i].view(redactor) - } - - return views, omitted -} - -// HandleEntrypointCreate handles adding a new entrypoint. -func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) - - return - } - - description := r.PostFormValue("description") - - entrypoint := &database.Entrypoint{ - WebhookID: webhook.ID, - Path: uuid.New().String(), - Description: description, - Active: true, - } - - err = h.db.DB().Create(entrypoint).Error - if err != nil { - h.serverError(w, "failed to create entrypoint", err) - - return - } - - http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, - ) - } -} - -// HandleTargetCreate handles adding a new target to a webhook. -func (h *Handlers) HandleTargetCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) - - return - } - - h.processTargetCreate(w, r, webhook) - } -} - -// processTargetCreate validates and creates a new target. -func (h *Handlers) processTargetCreate( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, -) { - // The body size cap is enforced by the MaxBodySize middleware, - // which runs before CSRF parses the form. - // - // Every field here is read with PostFormValue, not FormValue. - // FormValue falls back to the query string, which would let - // `POST /source/{id}/targets?url=https://hooks.slack.com/...` - // configure a target from a value the request line carries — and - // the request line, unlike the body, is what logs, proxies, - // Referer headers and error trackers record. - name := r.PostFormValue("name") - targetType := database.TargetType(r.PostFormValue("type")) - - if name == "" { - http.Error( - w, "Name is required", http.StatusBadRequest, - ) - - return - } - - if !isValidTargetType(targetType) { - http.Error( - w, "Invalid target type", - http.StatusBadRequest, - ) - - return - } - - configJSON, err := h.buildTargetConfig( - w, r, targetType, targetFormInputFrom(r), - ) - if err != nil { - return - } - - // A new target has no stored retry count, so an absent field - // takes the fire-and-forget default. A field the operator filled - // in with something invalid is rejected rather than becoming - // that default. - maxRetries, ok := targetMaxRetries(w, r, 0) - if !ok { - return - } - - target := &database.Target{ - WebhookID: webhook.ID, - Name: name, - Type: targetType, - Active: true, - Config: configJSON, - MaxRetries: maxRetries, - } - - err = h.db.DB().Create(target).Error - if err != nil { - h.serverError(w, "failed to create target", err) - - return - } - - http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, - ) -} - -// isValidTargetType checks whether the target type is supported. -func isValidTargetType(tt database.TargetType) bool { - switch tt { - case database.TargetTypeHTTP, - database.TargetTypeDatabase, - database.TargetTypeLog, - database.TargetTypeSlack: - return true - default: - return false - } -} - -// pageOrFirst parses a paginated page number, answering 1 for -// anything empty, unparseable or out of range. -// -// Falling back rather than rejecting is correct here and only here: -// a page number is where to send the browser next, not configuration -// the operator is storing, and the actions that submit one have -// already completed by the time it is read — answering 400 would -// report a failure that did not happen. Anything an operator SETS -// must be validated instead; see parseMaxRetries. -func pageOrFirst(s string) int { - v, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || v < 1 { - return 1 - } - - return v -} - -// targetFormInput carries the raw form values describing a target's -// configuration. Both the create and the edit path fill one and hand -// it to buildTargetConfig, so neither can come to validate a -// destination differently from the other. -type targetFormInput struct { - // URL is the destination for an HTTP target and the webhook URL - // for a Slack target. - URL string - // Headers is an HTTP target's headers, one "Name: value" per - // line. - Headers string - // Timeout is an HTTP target's per-request timeout in seconds. - Timeout string - // Expiry is a database (archive) target's row expiry. - Expiry string -} - -// targetFormInputFrom reads the configuration fields from a request -// body. The body size cap is enforced by the MaxBodySize middleware, -// which runs before CSRF parses the form. -// -// Every field is read with PostFormValue, not FormValue. FormValue -// falls back to the query string, which would let -// `POST /source/{id}/targets?url=https://hooks.slack.com/...` -// configure a target from a value the request line carries — and the -// request line, unlike the body, is what logs, proxies, Referer -// headers and error trackers record. The headers field is under the -// same rule and for the same reason: its values are authorization -// tokens. -func targetFormInputFrom(r *http.Request) targetFormInput { - return targetFormInput{ - URL: r.PostFormValue("url"), - Headers: r.PostFormValue("headers"), - Timeout: r.PostFormValue("timeout"), - Expiry: r.PostFormValue("expiry"), - } -} - -// buildTargetConfig builds the JSON config string for a target from -// the submitted form values, writing its own 4xx response on -// rejection. Which fields of in apply depends on the target type. -func (h *Handlers) buildTargetConfig( - w http.ResponseWriter, - r *http.Request, - targetType database.TargetType, - in targetFormInput, -) (string, error) { - switch targetType { - case database.TargetTypeHTTP: - return h.buildHTTPTargetConfig(w, r, in) - case database.TargetTypeSlack: - return h.buildSlackTargetConfig(w, r, in.URL) - case database.TargetTypeDatabase: - return h.buildDatabaseTargetConfig(w, in.Expiry) - case database.TargetTypeLog: - return "", nil - default: - http.Error( - w, "Invalid target type", - http.StatusBadRequest, - ) - - return "", errMissingURL - } -} - -// buildHTTPTargetConfig builds config JSON for an HTTP target: an -// SSRF-validated destination plus the optional headers and timeout -// the delivery path honours. -func (h *Handlers) buildHTTPTargetConfig( - w http.ResponseWriter, - r *http.Request, - in targetFormInput, -) (string, error) { - err := h.validateTargetURL( - w, r, in.URL, "URL is required for HTTP targets", - ) - if err != nil { - return "", err - } - - headers, err := delivery.ParseTargetHeaders(in.Headers) - if err != nil { - http.Error( - w, - "Invalid headers: "+err.Error(), - http.StatusBadRequest, - ) - - return "", err - } - - timeout, err := delivery.ParseTargetTimeout(in.Timeout) - if err != nil { - http.Error( - w, - "Invalid timeout: "+err.Error(), - http.StatusBadRequest, - ) - - return "", err - } - - return marshalTargetConfig(w, delivery.HTTPTargetConfig{ - URL: in.URL, - Headers: headers, - Timeout: timeout, - }) -} - -// buildSlackTargetConfig builds config JSON for a Slack target, -// whose whole configuration is one SSRF-validated webhook URL. -func (h *Handlers) buildSlackTargetConfig( - w http.ResponseWriter, - r *http.Request, - targetURL string, -) (string, error) { - err := h.validateTargetURL( - w, r, targetURL, - "Webhook URL is required for Slack targets", - ) - if err != nil { - return "", err - } - - return marshalTargetConfig(w, delivery.SlackTargetConfig{ - WebhookURL: targetURL, - }) -} - -// validateTargetURL rejects an empty or SSRF-blocked destination, -// writing the 400 itself. missingMsg is the error shown when no URL -// is given. -// -// It is the single point at which a user-supplied destination enters -// the SSRF guard, on create and on edit alike. An edit path that -// reached storage without passing through here would reopen the hole -// the guard closes. -func (h *Handlers) validateTargetURL( - w http.ResponseWriter, - r *http.Request, - targetURL, missingMsg string, -) error { - if targetURL == "" { - http.Error( - w, - missingMsg, - http.StatusBadRequest, - ) - - return errMissingURL - } - - err := h.ssrf.ValidateTargetURL( - r.Context(), targetURL, - ) - if err != nil { - // The submitted URL can be a credential (a Slack - // incoming webhook URL is a bearer token), so the log - // records only its scheme and host. - h.log.Warn( - "target URL blocked by SSRF protection", - "url", delivery.MaskURL(targetURL), - "error", err, - ) - http.Error( - w, - "Invalid target URL: "+err.Error(), - http.StatusBadRequest, - ) - - return err - } - - return nil -} - -// marshalTargetConfig serialises a target configuration for storage, -// writing a 500 itself if it cannot. -func marshalTargetConfig( - w http.ResponseWriter, - cfg any, -) (string, error) { - configBytes, err := json.Marshal(cfg) - if err != nil { - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) - - return "", err - } - - return string(configBytes), nil -} - -// buildDatabaseTargetConfig builds config JSON for a database -// (archive) target. The optional expiry (a form value read by -// the caller, which bounds the request body) is validated here, -// at creation time, so an unparseable value is rejected with a -// 400 instead of failing every subsequent delivery. An empty -// expiry yields an empty config (the keep-forever default). -func (h *Handlers) buildDatabaseTargetConfig( - w http.ResponseWriter, - expiry string, -) (string, error) { - expiry = strings.TrimSpace(expiry) - if expiry == "" { - return "", nil - } - - err := delivery.ValidateArchiveExpiry(expiry) - if err != nil { - http.Error( - w, - "Invalid archive expiry: "+err.Error(), - http.StatusBadRequest, - ) - - return "", err - } - - return marshalTargetConfig( - w, map[string]any{"expiry": expiry}, - ) -} - -// HandleEntrypointDelete handles deleting an entrypoint. -func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { - return h.deleteChildResource( - "entrypointID", &database.Entrypoint{}, - "failed to delete entrypoint", - nil, - ) -} - -// HandleTargetDelete handles deleting a target. Deleting the -// last database target of a webhook leaves its archive writer -// with nothing to write, so the writer is evicted and its -// handle closed; the archive file is left on disk. -func (h *Handlers) HandleTargetDelete() http.HandlerFunc { - return h.deleteChildResource( - "targetID", &database.Target{}, - "failed to delete target", - h.evictArchiveWriterIfUnused, - ) -} - -// deleteChildResource returns a handler that deletes a child -// resource (entrypoint or target) belonging to a webhook. The -// optional afterDelete hook runs with the webhook's id once the -// delete has succeeded, before the redirect. -func (h *Handlers) deleteChildResource( - idParam string, - model any, - errMsg string, - afterDelete func(webhookID string), -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - childID := chi.URLParam(r, idParam) - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - result := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhook.ID, - ).Delete(model) - if result.Error != nil { - h.log.Error(errMsg, "error", result.Error) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) - - return - } - - if afterDelete != nil { - afterDelete(webhook.ID) - } - - http.Redirect( - w, r, - "/source/"+webhook.ID, - http.StatusSeeOther, - ) - } -} - -// HandleEntrypointToggle handles toggling an entrypoint's -// active state. -func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { - return h.toggleChildResource( - "entrypointID", - func(webhookID, childID string) error { - var ep database.Entrypoint - - err := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhookID, - ).First(&ep).Error - if err != nil { - return err - } - - ep.Active = !ep.Active - - return h.db.DB().Save(&ep).Error - }, - "failed to toggle entrypoint", - ) -} - -// HandleTargetToggle handles toggling a target's active state. -func (h *Handlers) HandleTargetToggle() http.HandlerFunc { - return h.toggleChildResource( - "targetID", - func(webhookID, childID string) error { - var tgt database.Target - - err := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhookID, - ).First(&tgt).Error - if err != nil { - return err - } - - tgt.Active = !tgt.Active - - return h.db.DB().Save(&tgt).Error - }, - "failed to toggle target", - ) -} - -// toggleChildResource returns a handler that toggles the active -// state of a child resource belonging to a webhook. -func (h *Handlers) toggleChildResource( - idParam string, - toggleFn func(webhookID, childID string) error, - errMsg string, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - childID := chi.URLParam(r, idParam) - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - http.NotFound(w, r) - - return - } - - err = toggleFn(webhook.ID, childID) - if err != nil { - h.log.Error(errMsg, "error", err) - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) - - return - } - - http.Redirect( - w, r, - "/source/"+webhook.ID, - http.StatusSeeOther, - ) - } -} - -// getUserID extracts the user ID from the session. -func (h *Handlers) getUserID( - r *http.Request, -) (string, bool) { - sess, err := h.session.Get(r) - if err != nil { - return "", false - } - - if !h.session.IsAuthenticated(sess) { - return "", false - } - - return h.session.GetUserID(sess) -} diff --git a/internal/handlers/source_management_test.go b/internal/handlers/source_management_test.go index 508af5d..1021df4 100644 --- a/internal/handlers/source_management_test.go +++ b/internal/handlers/source_management_test.go @@ -2,16 +2,20 @@ package handlers_test import ( "context" + "errors" "net/http" "net/http/httptest" "net/url" "strconv" "strings" + "sync/atomic" "testing" + "time" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" @@ -105,7 +109,7 @@ func submitCreate( form.Set("retention_days", *retention) } - req := formRequest("/sources/new", cookies, form, nil) + req := formRequest("/hooks/new", cookies, form, nil) w := httptest.NewRecorder() h.HandleSourceCreateSubmit().ServeHTTP(w, req) @@ -187,6 +191,8 @@ func storedRetentionDays( type sourceTestEnv struct { handlers *handlers.Handlers db *database.Database + dbMgr *database.WebhookDBManager + archives *recordingArchives cookies []*http.Cookie } @@ -199,7 +205,11 @@ func setupSourceTest(t *testing.T) *sourceTestEnv { var db *database.Database - app := newTestApp(t, &h, &sess, &db) + var dbMgr *database.WebhookDBManager + + var archives *recordingArchives + + app := newTestApp(t, &h, &sess, &db, &dbMgr, &archives) app.RequireStart() t.Cleanup(app.RequireStop) @@ -207,6 +217,8 @@ func setupSourceTest(t *testing.T) *sourceTestEnv { return &sourceTestEnv{ handlers: h, db: db, + dbMgr: dbMgr, + archives: archives, cookies: authenticatedCookies( t, sess, sourceTestUserID, "sourceuser", ), @@ -265,7 +277,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) { w := httptest.NewRecorder() env.handlers.HandleSourceCreate().ServeHTTP( - w, getRequest(t, "/sources/new", env.cookies, nil), + w, getRequest(t, "/hooks/new", env.cookies, nil), ) require.Equal(t, http.StatusOK, w.Code) @@ -360,31 +372,42 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected( // boundary between "too large to represent" and "retain forever": the // sentinel is above MaxFiniteRetentionDays, but it is the value the // edit form pre-fills, so it must be accepted rather than rejected as -// out of range. +// out of range. A value above the sentinel is stored as the sentinel. func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever( t *testing.T, ) { t.Parallel() - env := setupSourceTest(t) - sentinel := strconv.Itoa(database.RetentionForeverDays) - - w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel) - require.Equal(t, http.StatusSeeOther, w.Code) - - wh := onlyWebhook(t, env.db) - assert.Equal( - t, + for _, days := range []int{ database.RetentionForeverDays, - storedRetentionDays(t, env.db, wh.ID), - ) + database.RetentionForeverDays + 1, + } { + raw := strconv.Itoa(days) + + t.Run(raw, func(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + w := submitCreate(t, env.handlers, env.cookies, "forever", &raw) + require.Equal(t, http.StatusSeeOther, w.Code) + + wh := onlyWebhook(t, env.db) + assert.Equal( + t, + database.RetentionForeverDays, + storedRetentionDays(t, env.db, wh.ID), + ) + }) + } } // TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a // validation failure hands the user's typing back, matching what the // edit form already does. Losing a long description to a mistyped // retention value is the kind of thing that makes people give up on a -// form. +// form. Both values carry HTML-special characters, which must come +// back escaped rather than as markup. func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( t *testing.T, ) { @@ -393,8 +416,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( env := setupSourceTest(t) const ( - name = "kept-name" - description = "a description worth not losing" + name = `kept">name` + description = `a worth not losing` ) form := url.Values{} @@ -402,7 +425,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( form.Set("description", description) form.Set("retention_days", "nonsense") - req := formRequest("/sources/new", env.cookies, form, nil) + req := formRequest("/hooks/new", env.cookies, form, nil) w := httptest.NewRecorder() env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req) @@ -411,8 +434,10 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( body := w.Body.String() - assert.Contains(t, body, `value="`+name+`"`) - assert.Contains(t, body, description) + assert.Contains(t, body, `value="kept"><b>name"`) + assert.Contains(t, body, `a </textarea> worth not losing`) + assert.NotContains(t, body, name) + assert.NotContains(t, body, description) } // submitEdit posts the webhook edit form for the given webhook. @@ -430,7 +455,7 @@ func submitEdit( form.Set("retention_days", retention) req := formRequest( - "/source/"+wh.ID+"/edit", + "/hook/"+wh.ID+"/edit", env.cookies, form, map[string]string{sourceIDParam: wh.ID}, @@ -484,6 +509,51 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected( ) } +// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for +// each reason the form can give and checks that the form comes back +// with the reason and the name, description and retention submitted, +// that the page still reports the stored retention, and that nothing +// is saved. +func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + refused := func(name, retention, reason string) { + t.Helper() + + wh := seedWebhookWithRetention(t, env.db, 30) + + submitted := wh + submitted.Name = name + submitted.Description = "a description worth keeping" + + w := submitEdit(t, env, submitted, retention) + assert.Equal(t, http.StatusBadRequest, w.Code) + + page := w.Body.String() + assert.Contains(t, page, `class="alert-error">`+reason) + assert.Contains(t, page, `name="name" value="`+name+`"`) + assert.Contains(t, page, ">a description worth keeping") + assert.Contains( + t, page, `name="retention_days" value="`+retention+`"`, + ) + assert.Contains(t, page, "Currently 30 days.") + + var stored database.Webhook + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", wh.ID).Error, + ) + assert.Equal(t, wh.Name, stored.Name) + assert.Empty(t, stored.Description) + assert.Equal(t, 30, stored.RetentionDays) + } + + refused("", "45", "Name is required") + refused("kept-name", "nonsense", "Retention must be") +} + func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged( t *testing.T, ) { @@ -498,6 +568,305 @@ func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged( assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID)) } +// renamedWebhookName is the name the rename tests give a webhook. +const renamedWebhookName = "Renamed" + +// TestHandleSourceEditSubmit_RenamesArchives proves that a save +// that keeps the webhook's name renames nothing, and that renaming a +// webhook renames the archive of each of its database targets and +// asks nothing of its other targets. +func TestHandleSourceEditSubmit_RenamesArchives(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + seedTarget(t, env.db, wh.ID, database.TargetTypeLog) + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusSeeOther, w.Code) + assert.Empty(t, env.archives.Renames()) + + wh.Name = renamedWebhookName + + w = submitEdit(t, env, wh, "") + require.Equal(t, http.StatusSeeOther, w.Code) + + assert.ElementsMatch( + t, + []archiveRename{ + {first.ID, renamedWebhookName, first.Name}, + {second.ID, renamedWebhookName, second.Name}, + }, + env.archives.Renames(), + ) +} + +// TestHandleSourceEditSubmit_FailedRenameKeepsTheName proves that a +// webhook whose archive cannot be renamed keeps its stored name, so +// the name on disk and the name in the UI do not part, and that the +// handler puts back what it may already have moved. +func TestHandleSourceEditSubmit_FailedRenameKeepsTheName( + t *testing.T, +) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + env.archives.FailRenames(tgt.ID, errInjectedRename) + + oldName := wh.Name + wh.Name = renamedWebhookName + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusInternalServerError, w.Code) + + var stored database.Webhook + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", wh.ID).Error, + ) + assert.Equal(t, oldName, stored.Name) + + assert.Equal( + t, + []archiveRename{ + {tgt.ID, renamedWebhookName, tgt.Name}, + {tgt.ID, oldName, tgt.Name}, + }, + env.archives.Renames(), + ) +} + +// TestHandleSourceEditSubmit_FailedSaveRenamesBack proves that when +// the archive is renamed but the new name cannot be saved, the +// archive is renamed back to the stored name and the stored name +// stays. +func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + failSaveOnTable(t, env.db, "webhooks") + + oldName := wh.Name + wh.Name = renamedWebhookName + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusInternalServerError, w.Code) + + var stored database.Webhook + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", wh.ID).Error, + ) + assert.Equal(t, oldName, stored.Name) + + assert.Equal( + t, + []archiveRename{ + {tgt.ID, renamedWebhookName, tgt.Name}, + {tgt.ID, oldName, tgt.Name}, + }, + env.archives.Renames(), + ) +} + +// errInjectedRead is the failure a test makes reads of the main +// database report. +var errInjectedRead = errors.New("injected read failure") + +// TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading +// proves that when the save fails and every later read of the main +// database fails too, each archive the rename moved is still renamed +// back: the move back needs no second read of the webhook's targets. +func TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading( + t *testing.T, +) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + var saveFailed atomic.Bool + + require.NoError(t, env.db.DB().Callback().Update(). + Before("gorm:update"). + Register("test:fail_save", func(tx *gorm.DB) { + saveFailed.Store(true) + + _ = tx.AddError(errInjectedSave) + }), + ) + require.NoError(t, env.db.DB().Callback().Query(). + Before("gorm:query"). + Register("test:fail_reads_after_save", func(tx *gorm.DB) { + if saveFailed.Load() { + _ = tx.AddError(errInjectedRead) + } + }), + ) + + oldName := wh.Name + wh.Name = renamedWebhookName + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusInternalServerError, w.Code) + + assert.Equal( + t, + []archiveRename{ + {first.ID, renamedWebhookName, first.Name}, + {second.ID, renamedWebhookName, second.Name}, + {first.ID, oldName, first.Name}, + {second.ID, oldName, second.Name}, + }, + env.archives.Renames(), + ) +} + +// TestHandleSourceEditSubmit_EditsDoNotInterleave proves that a second +// webhook edit submitted while the first is inside its archive rename +// does not run until the first is saved, so afterwards the stored +// names are the ones the archive was last renamed to. The stand-in's +// last rename is the name the file has on disk. +func TestHandleSourceEditSubmit_EditsDoNotInterleave(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + entered, release := env.archives.BlockNextRename() + + firstEdit, secondEdit := wh, wh + firstEdit.Name = "First" + secondEdit.Name = "Second" + + firstCode := make(chan int, 1) + + go func() { firstCode <- submitEdit(t, env, firstEdit, "").Code }() + + <-entered + + secondCode := make(chan int, 1) + + go func() { secondCode <- submitEdit(t, env, secondEdit, "").Code }() + + // Were the edits not ordered, the second would run to its end in + // this time, while the first is still inside its rename. + time.Sleep(200 * time.Millisecond) + release() + + assert.Equal(t, http.StatusSeeOther, <-firstCode) + assert.Equal(t, http.StatusSeeOther, <-secondCode) + + var ( + storedWebhook database.Webhook + storedTarget database.Target + ) + + require.NoError( + t, env.db.DB().First(&storedWebhook, "id = ?", wh.ID).Error, + ) + require.NoError( + t, env.db.DB().First(&storedTarget, "id = ?", tgt.ID).Error, + ) + + renames := env.archives.Renames() + require.NotEmpty(t, renames) + assert.Equal( + t, + archiveRename{tgt.ID, storedWebhook.Name, storedTarget.Name}, + renames[len(renames)-1], + ) +} + +// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves +// that when a webhook has three database targets and only the middle +// one's archive cannot be renamed, the stored name stays and both +// others are renamed back, the last one included: the move back does +// not stop at the target it cannot rename. The handler reaches the +// targets in the order they were created, which the exact sequence +// below pins, so the refused target always comes before the last. +func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack( + t *testing.T, +) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + middle := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + last := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + env.archives.FailRenames(middle.ID, errNameTaken) + + oldName := wh.Name + wh.Name = renamedWebhookName + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusConflict, w.Code) + + var stored database.Webhook + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", wh.ID).Error, + ) + assert.Equal(t, oldName, stored.Name) + + assert.Equal( + t, + []archiveRename{ + {first.ID, renamedWebhookName, first.Name}, + {middle.ID, renamedWebhookName, middle.Name}, + {last.ID, renamedWebhookName, last.Name}, + {first.ID, oldName, first.Name}, + {middle.ID, oldName, middle.Name}, + {last.ID, oldName, last.Name}, + }, + env.archives.Renames(), + ) +} + +// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file +// already has an archive's new name, the edit is refused with an +// error naming that file, and the webhook keeps its stored name. +func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + env.archives.FailRenames(tgt.ID, errNameTaken) + + oldName := wh.Name + wh.Name = renamedWebhookName + + w := submitEdit(t, env, wh, "") + require.Equal(t, http.StatusConflict, w.Code) + assert.Contains(t, w.Body.String(), "archive-taken.db") + assert.Contains( + t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`, + "the form comes back with the name submitted", + ) + + var stored database.Webhook + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", wh.ID).Error, + ) + assert.Equal(t, oldName, stored.Name) +} + // TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that // the removed max="365" cap used to break: render the edit form for a // retain-forever webhook, confirm the pre-filled sentinel is not capped @@ -512,7 +881,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { ) req := getRequest( - t, "/source/"+wh.ID+"/edit", env.cookies, + t, "/hook/"+wh.ID+"/edit", env.cookies, map[string]string{sourceIDParam: wh.ID}, ) w := httptest.NewRecorder() @@ -567,7 +936,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( listW := httptest.NewRecorder() env.handlers.HandleSourceList().ServeHTTP( - listW, getRequest(t, "/sources", env.cookies, nil), + listW, getRequest(t, "/hooks", env.cookies, nil), ) require.Equal(t, http.StatusOK, listW.Code) @@ -578,7 +947,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( env.handlers.HandleSourceDetail().ServeHTTP( detailW, getRequest( - t, "/source/"+wh.ID, env.cookies, + t, "/hook/"+wh.ID, env.cookies, map[string]string{sourceIDParam: wh.ID}, ), ) diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go new file mode 100644 index 0000000..224cb3f --- /dev/null +++ b/internal/handlers/target_create.go @@ -0,0 +1,389 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// HandleTargetCreate handles adding a new target to a webhook. +func (h *Handlers) HandleTargetCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + h.renameMu.Lock() + defer h.renameMu.Unlock() + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + h.processTargetCreate(w, r, webhook) + } +} + +// processTargetCreate validates and creates a new target. A refused +// submission shows the webhook page again, with the add target form +// open on the chosen type, the values entered, and the reason. +func (h *Handlers) processTargetCreate( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, +) { + in := targetFormInputFrom(r) + + target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in) + if err != nil { + h.serverError(w, r, "failed to encode target config", err) + + return + } + + if errMsg != "" { + h.renderSourceDetail(w, r, webhook, in, errMsg) + + return + } + + err = h.db.DB().Create(target).Error + if err != nil { + h.serverError(w, r, "failed to create target", err) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, targetAdded), + http.StatusSeeOther, + ) +} + +// newTarget validates a new target for a webhook and returns the row +// to create, or, when it refuses the target, the message the form +// shows. An error is the server's fault, not a refusal: the accepted +// configuration could not be encoded. Every form that creates a +// target goes through here, so they all accept and refuse the same +// things. +func (h *Handlers) newTarget( + ctx context.Context, + webhookID string, + in targetFormInput, +) (*database.Target, string, error) { + target := &database.Target{ + WebhookID: webhookID, + Type: in.Type, + Active: true, + } + + errMsg, err := h.setTargetFromForm(ctx, target, in) + if err != nil || errMsg != "" { + return nil, errMsg, err + } + + return target, "", nil +} + +// setTargetFromForm validates a target form against the target's type +// and, when it accepts it, sets the target's name, configuration and +// retry count from it. It returns the message the form shows for +// anything it refuses, an unknown type among them, and then leaves the +// target unchanged; an error is the server's fault, as for newTarget. +// The add target form and the target edit form both go through here, +// so the two cannot come to disagree about what a target may be. +func (h *Handlers) setTargetFromForm( + ctx context.Context, + target *database.Target, + in targetFormInput, +) (string, error) { + if in.Name == "" { + return "Name is required", nil + } + + configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in) + if err != nil || errMsg != "" { + return errMsg, err + } + + // An empty max_retries keeps the target's count: the + // fire-and-forget default of 0 for a new target, and the stored + // count for an edited one, since the forms for target types that + // do not retry have no such field. A value that is filled in but + // invalid is refused rather than becoming that count, so a typo + // cannot destroy the count a target is delivering with. + maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) + if err != nil { + return "Invalid delivery attempts: " + retriesErrorMessage(err), nil + } + + target.Name = in.Name + target.Config = configJSON + target.MaxRetries = maxRetries + + return "", nil +} + +// targetFormInput carries the raw values of a target form. Both the +// create and the edit path fill one and hand it to setTargetFromForm, +// so neither can come to validate a target differently from the +// other. Both forms are filled from one: the edit form with the +// stored values, and a refused form with the values submitted. +type targetFormInput struct { + // Name is the target's name. + Name string + // Type is the type chosen on the add target form. The edit form + // has none: a target's stored type decides. + Type database.TargetType + // URL is the destination for an HTTP target and the webhook URL + // for a Slack target. + URL string + // Headers is an HTTP target's headers, one "Name: value" per + // line. + Headers string + // Timeout is an HTTP target's per-request timeout in seconds. + Timeout string + // ForwardQuery is an HTTP target's checkbox that passes each + // event's query string on to it. + ForwardQuery bool + // MaxRetries is an HTTP or Slack target's max_retries. + MaxRetries string + // Expiry is a database (archive) target's row expiry. + Expiry string + // Rotation is a database (archive) target's rotation. + Rotation string +} + +// targetFormInputFrom reads a target form from a request body. The +// body size cap is enforced by the MaxBodySize middleware, which runs +// before CSRF parses the form. +// +// Every field is read with PostFormValue, not FormValue. FormValue +// falls back to the query string, which would let +// `POST /hook/{id}/targets?url=https://hooks.slack.com/...` +// configure a target from a value the request line carries — and the +// request line, unlike the body, is what logs, proxies, Referer +// headers and error trackers record. The headers field is under the +// same rule and for the same reason: its values are authorization +// tokens. +func targetFormInputFrom(r *http.Request) targetFormInput { + return targetFormInput{ + Name: r.PostFormValue("name"), + Type: database.TargetType(r.PostFormValue("type")), + URL: r.PostFormValue("url"), + Headers: r.PostFormValue("headers"), + Timeout: r.PostFormValue("timeout"), + ForwardQuery: r.PostFormValue("forward_query") != "", + MaxRetries: r.PostFormValue("max_retries"), + Expiry: r.PostFormValue("expiry"), + Rotation: r.PostFormValue("rotation"), + } +} + +// buildTargetConfig builds the JSON config string for a target from +// the submitted form values, or returns the message the form shows +// for a value it refuses. An error is the server's fault, not a +// refusal: the accepted configuration could not be encoded. Which +// fields of in apply depends on the target type; a type without a URL +// ignores any URL submitted. +func (h *Handlers) buildTargetConfig( + ctx context.Context, + targetType database.TargetType, + in targetFormInput, +) (string, string, error) { + switch targetType { + case database.TargetTypeHTTP: + return h.buildHTTPTargetConfig(ctx, in) + case database.TargetTypeSlack: + return h.buildSlackTargetConfig(ctx, in.URL) + case database.TargetTypeDatabase: + return buildDatabaseTargetConfig(in.Expiry, in.Rotation) + case database.TargetTypeLog: + return "", "", nil + default: + return "", "Invalid target type", nil + } +} + +// buildHTTPTargetConfig builds config JSON for an HTTP target: an +// SSRF-validated destination plus the optional headers, timeout and +// query string setting the delivery path honours. +func (h *Handlers) buildHTTPTargetConfig( + ctx context.Context, + in targetFormInput, +) (string, string, error) { + errMsg := h.validateTargetURL( + ctx, in.URL, "URL is required for HTTP targets", + ) + if errMsg != "" { + return "", errMsg, nil + } + + headers, err := delivery.ParseTargetHeaders(in.Headers) + if err != nil { + return "", fmt.Sprintf("Invalid headers: %v", err), nil + } + + timeout, err := delivery.ParseTargetTimeout(in.Timeout) + if err != nil { + return "", fmt.Sprintf("Invalid timeout: %v", err), nil + } + + configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ + URL: in.URL, + Headers: headers, + Timeout: timeout, + ForwardQuery: in.ForwardQuery, + }) + + return configJSON, "", err +} + +// buildSlackTargetConfig builds config JSON for a Slack target, +// whose whole configuration is one SSRF-validated webhook URL. +func (h *Handlers) buildSlackTargetConfig( + ctx context.Context, + targetURL string, +) (string, string, error) { + errMsg := h.validateTargetURL( + ctx, targetURL, + "Webhook URL is required for Slack targets", + ) + if errMsg != "" { + return "", errMsg, nil + } + + configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{ + WebhookURL: targetURL, + }) + + return configJSON, "", err +} + +// validateTargetURL refuses an empty or SSRF-blocked destination, +// returning the message the form shows, or "" when the destination +// is accepted. missingMsg is the message for no URL at all. +// +// It is the single point at which a user-supplied destination enters +// the SSRF guard, on create and on edit alike. An edit path that +// reached storage without passing through here would reopen the hole +// the guard closes. +func (h *Handlers) validateTargetURL( + ctx context.Context, + targetURL, missingMsg string, +) string { + if targetURL == "" { + return missingMsg + } + + err := h.ssrf.ValidateTargetURL(ctx, targetURL) + if err != nil { + // The submitted URL can be a credential (a Slack + // incoming webhook URL is a bearer token), so the log + // records only its scheme and host. + h.log.Warn( + "target URL blocked by SSRF protection", + "url", delivery.MaskURL(targetURL), + "error", err, + ) + + msg := "Invalid target URL: " + err.Error() + + // Only a private or reserved address's refusal says how + // to allow it. Other refusals never do: link-local, the + // unspecified addresses and the unconditional metadata + // addresses cannot be opened, and the default + // blocklist's public addresses, which listing does open, + // hand out credentials. + if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { + msg += ". Private and reserved addresses are refused " + + "by default; the server's ALLOWED_EGRESS_CIDRS " + + "setting allows named networks (see \"Allowing " + + "egress to your own network\" in the README)." + } + + return msg + } + + return "" +} + +// marshalTargetConfig serialises a target configuration for storage. +func marshalTargetConfig(cfg any) (string, error) { + configBytes, err := json.Marshal(cfg) + if err != nil { + return "", err + } + + return string(configBytes), nil +} + +// buildDatabaseTargetConfig builds config JSON for a database +// (archive) target. The optional expiry and rotation are validated +// here, at creation time, so a bad value is refused instead of +// failing every subsequent delivery. Each is stored only when set, +// and with neither the config is empty (the keep-forever, one-file +// default). +func buildDatabaseTargetConfig( + expiry, rotation string, +) (string, string, error) { + expiry = strings.TrimSpace(expiry) + + err := delivery.ValidateArchiveExpiry(expiry) + if err != nil { + return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil + } + + err = delivery.ValidateArchiveRotation(rotation) + if err != nil { + return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil + } + + cfg := map[string]any{} + + if expiry != "" { + cfg["expiry"] = expiry + } + + if rotation != "" { + cfg["rotation"] = rotation + } + + if len(cfg) == 0 { + return "", "", nil + } + + configJSON, err := marshalTargetConfig(cfg) + + return configJSON, "", err +} diff --git a/internal/handlers/target_create_query_test.go b/internal/handlers/target_create_query_test.go index 84b0b5e..3eb3914 100644 --- a/internal/handlers/target_create_query_test.go +++ b/internal/handlers/target_create_query_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // targetSecretSegments are the path segments of an incoming-webhook @@ -65,7 +65,8 @@ func postTargetCreate( t.Helper() logBuf := new(bytes.Buffer) - mw := middleware.NewForTest( + mw := middlewaretest.New( + t, slog.New(slog.NewJSONHandler( logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}, )), @@ -76,11 +77,11 @@ func postTargetCreate( router := chi.NewRouter() router.Use(mw.Logging()) router.Post( - "/source/{sourceID}/targets", + "/hook/{sourceID}/targets", env.handlers.HandleTargetCreate(), ) - target := "/source/" + webhookID + "/targets" + target := "/hook/" + webhookID + "/targets" if query != "" { target += "?" + query } @@ -114,7 +115,7 @@ func postTargetCreate( // regression test for the ingress leak. r.FormValue falls back to the // query string when a field is absent from the POST body, so // -// POST /source/{id}/targets?url=https://hooks.slack.com/services/... +// POST /hook/{id}/targets?url=https://hooks.slack.com/services/... // // with an empty url field used to create a working target from a value // carried on the request line — where logs, proxies, Referer headers diff --git a/internal/handlers/target_create_test.go b/internal/handlers/target_create_test.go new file mode 100644 index 0000000..253f63e --- /dev/null +++ b/internal/handlers/target_create_test.go @@ -0,0 +1,154 @@ +package handlers_test + +import ( + "html" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestHandleTargetCreate_EveryType adds a target of each type. Each +// submission carries a url: only the http and slack types store one. +func TestHandleTargetCreate_EveryType(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + // fields is the rest of each submission, as a query string. + cases := []struct { + targetType database.TargetType + fields string + wantConfig string + wantRetries int + }{ + { + database.TargetTypeHTTP, "timeout=12&max_retries=3", + `{"url":"` + editOriginalURL + `","timeout":12}`, 3, + }, + { + database.TargetTypeSlack, "max_retries=4", + `{"webhookUrl":"` + editOriginalURL + `"}`, 4, + }, + { + database.TargetTypeDatabase, "expiry=720h", + `{"expiry":"720h"}`, 0, + }, + {database.TargetTypeLog, "", "", 0}, + } + + for _, tc := range cases { + t.Run(string(tc.targetType), func(t *testing.T) { + t.Parallel() + + webhook := seedWebhookWithRetention(t, env.db, 30) + + form, err := url.ParseQuery(tc.fields) + require.NoError(t, err) + form.Set("name", "every-type") + form.Set("type", string(tc.targetType)) + form.Set("url", editOriginalURL) + + w := serveTarget( + env, http.MethodPost, + "/hook/"+webhook.ID+"/targets", form, + ) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.Equal(t, tc.targetType, targets[0].Type) + assert.Equal(t, tc.wantRetries, targets[0].MaxRetries) + + if tc.wantConfig == "" { + assert.Empty(t, targets[0].Config) + } else { + assert.JSONEq(t, tc.wantConfig, targets[0].Config) + } + }) + } +} + +// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each +// type and checks that the webhook page comes back with the add target +// form open on that type, the values entered, and the reason. +func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + // fields is what the operator typed, as a query string. + cases := []struct { + targetType database.TargetType + fields string + reason string + }{ + { + database.TargetTypeHTTP, + "name=private&url=" + editBlockedURL + + "&timeout=12&max_retries=3", + "Invalid target URL", + }, + { + database.TargetTypeSlack, "name=no-url&max_retries=4", + "Webhook URL is required for Slack targets", + }, + { + database.TargetTypeDatabase, "name=archive&expiry=7d", + "Invalid archive expiry", + }, + {database.TargetTypeLog, "name=", "Name is required"}, + } + + for _, tc := range cases { + t.Run(string(tc.targetType), func(t *testing.T) { + t.Parallel() + + webhook := seedWebhookWithRetention(t, env.db, 30) + + typed, err := url.ParseQuery(tc.fields) + require.NoError(t, err) + + form := url.Values{} + form.Set("type", string(tc.targetType)) + + for field := range typed { + form.Set(field, typed.Get(field)) + } + + w := serveTarget( + env, http.MethodPost, + "/hook/"+webhook.ID+"/targets", form, + ) + assert.Equal(t, http.StatusBadRequest, w.Code) + + page := w.Body.String() + assert.Contains( + t, page, `data-type="`+string(tc.targetType)+`"`, + ) + assert.Contains(t, page, html.EscapeString(tc.reason)) + + // Each value comes back in a data attribute of the targets + // section named after its field (max_retries as + // data-max-retries), except url, which comes back in + // data-destination; templates/source_detail.html says why. + for field := range typed { + attr := "data-" + strings.ReplaceAll(field, "_", "-") + if field == "url" { + attr = "data-destination" + } + + assert.Contains( + t, page, attr+`="`+ + html.EscapeString(typed.Get(field))+`"`, + ) + } + + assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID)) + }) + } +} diff --git a/internal/handlers/target_delete.go b/internal/handlers/target_delete.go new file mode 100644 index 0000000..4402b3a --- /dev/null +++ b/internal/handlers/target_delete.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleTargetDelete handles deleting a target. A deleted +// database target's archive writer is evicted and its handle +// closed; the archive file is left on disk. +func (h *Handlers) HandleTargetDelete() http.HandlerFunc { + return h.deleteChildResource( + "targetID", &database.Target{}, + "failed to delete target", + h.evictTargetArchiveWriter, + targetDeleted, + ) +} + +// evictTargetArchiveWriter is evictArchiveWriter for one deleted +// target, and leaves its archive file on disk for the same reason. +// A target that is not a database target has no writer, and +// evicting it does nothing. +func (h *Handlers) evictTargetArchiveWriter(targetID string) { + if h.archives == nil { + return + } + + h.archives.EvictTarget(targetID) +} diff --git a/internal/handlers/target_download.go b/internal/handlers/target_download.go new file mode 100644 index 0000000..2638d5a --- /dev/null +++ b/internal/handlers/target_download.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "time" + + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// downloadWriteTimeout is how long one write of a download may wait +// for a client that has stopped reading. +const downloadWriteTimeout = 60 * time.Second + +// HandleTargetDownload serves a database target's archive as one +// gzipped JSON file, named for the webhook, the target and the time; +// see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other +// target types have no archive and are a 404. +// +// A download runs for as long as the client keeps reading: it reads +// under a context the request limit does not cancel, and gives each +// write its own deadline in place of the server's write timeout. It +// stops when a write fails. +func (h *Handlers) HandleTargetDownload() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithoutCancel(r.Context()) + + webhook, target, export, ok := h.listTargetArchive(w, r) + if !ok { + return + } + + now := time.Now() + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set( + "Content-Disposition", + `attachment; filename="`+delivery.ArchiveExportFileName( + webhook.Name, target.Name, now, + )+`"`, + ) + + err := export.WriteGzipJSON( + ctx, + downloadWriter{w: w, rc: http.NewResponseController(w)}, + &webhook, target, now, + ) + if err != nil { + h.log.Error( + "failed to export archive", + "target_id", target.ID, + "error", err, + ) + + // The 200 has gone out. Aborting the connection is what + // tells the client the file is incomplete. + panic(http.ErrAbortHandler) + } + } +} + +// downloadWriter writes a download to the client, giving each write +// downloadWriteTimeout to finish. +type downloadWriter struct { + w http.ResponseWriter + rc *http.ResponseController +} + +func (d downloadWriter) Write(b []byte) (int, error) { + // A writer that has no write deadline, such as a test's recorder, + // answers http.ErrNotSupported and needs none extended. + err := d.rc.SetWriteDeadline(time.Now().Add(downloadWriteTimeout)) + if err != nil && !errors.Is(err, http.ErrNotSupported) { + return 0, err + } + + return d.w.Write(b) +} + +// listTargetArchive lists the archive files of the request's database +// target for export. It reports false once it has written the response. +// +// It holds renameMu, which every archive rename runs under, while it +// reads the stored names and lists the files, so the files it lists +// are the ones those names give. It lets go before the export is +// streamed, which takes renameMu again for each file only while it +// finds the file under the names stored then and opens it. +func (h *Handlers) listTargetArchive( + w http.ResponseWriter, + r *http.Request, +) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) { + h.renameMu.Lock() + defer h.renameMu.Unlock() + + webhook, target, ok := h.ownedTarget(w, r) + if !ok { + return database.Webhook{}, nil, nil, false + } + + if target.Type != database.TargetTypeDatabase { + h.renderError(w, r, http.StatusNotFound) + + return database.Webhook{}, nil, nil, false + } + + export, err := delivery.NewArchiveExport( + delivery.ArchivePath(h.dbMgr, &webhook, target), + &h.renameMu, + func() (string, error) { + return h.storedArchivePath(webhook.ID, target.ID) + }, + h.log, + ) + if err != nil { + h.serverError(w, r, "failed to list archive for export", err) + + return database.Webhook{}, nil, nil, false + } + + return webhook, target, export, true +} + +// storedArchivePath returns the path delivery.ArchivePath gives a +// database target under the names stored for it and its webhook now. +// Its caller holds renameMu. A webhook or target deleted since is still +// found, since deleting one leaves its archive files under their names. +func (h *Handlers) storedArchivePath( + webhookID, targetID string, +) (string, error) { + var webhook database.Webhook + + err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error + if err != nil { + return "", err + } + + var target database.Target + + err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error + if err != nil { + return "", err + } + + return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil +} diff --git a/internal/handlers/target_download_test.go b/internal/handlers/target_download_test.go new file mode 100644 index 0000000..f52b6c7 --- /dev/null +++ b/internal/handlers/target_download_test.go @@ -0,0 +1,452 @@ +package handlers_test + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" +) + +// errClientGone is the write failure of a client that has gone away. +var errClientGone = errors.New("client gone") + +// downloadPath is the archive download route of a target. +func downloadPath(webhookID, targetID string) string { + return "/hook/" + webhookID + "/targets/" + targetID + "/download" +} + +// renameTarget submits the edit form renaming a target to Renamed. +func renameTarget( + env *sourceTestEnv, webhookID, targetID string, +) *httptest.ResponseRecorder { + form := url.Values{} + form.Set("name", "Renamed") + + return submitTargetEdit(env, webhookID, targetID, form) +} + +// TestHandleTargetDownload proves a database target's archive +// downloads as a gzipped JSON attachment named for the webhook, the +// target and the time, here with no archive file yet, so with no +// rows; and that a target of another type has no download. +func TestHandleTargetDownload(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + logTarget := seedTarget(t, env.db, wh.ID, database.TargetTypeLog) + + w := serveTarget( + env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil, + ) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, "application/gzip", w.Header().Get("Content-Type")) + assert.Regexp(t, + `^attachment; filename="archive-seeded-t-database-`+ + `\d{8}T\d{6}Z\.json\.gz"$`, + w.Header().Get("Content-Disposition"), + ) + + zr, err := gzip.NewReader(w.Body) + require.NoError(t, err) + + var got map[string]json.RawMessage + + require.NoError(t, json.NewDecoder(zr).Decode(&got)) + assert.JSONEq(t, + `{"id":"`+archive.ID+`","name":"t-database"}`, + string(got["target"]), + ) + assert.JSONEq(t, `[]`, string(got["archived_events"])) + + w = serveTarget( + env, http.MethodGet, downloadPath(wh.ID, logTarget.ID), nil, + ) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestHandleTargetDownload_WaitsForRename proves a download reads the +// target's names and lists its archive under the lock a rename holds: +// started while an edit is renaming the archive, it waits, and is +// named for the target's new name. +func TestHandleTargetDownload_WaitsForRename(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + renaming, release := env.archives.BlockNextRename() + edited := make(chan *httptest.ResponseRecorder, 1) + + go func() { + edited <- renameTarget(env, wh.ID, archive.ID) + }() + + <-renaming + + downloaded := make(chan *httptest.ResponseRecorder, 1) + + go func() { + downloaded <- serveTarget( + env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil, + ) + }() + + select { + case <-downloaded: + release() + t.Fatal("the download did not wait for the rename") + case <-time.After(100 * time.Millisecond): + } + + release() + require.Equal(t, http.StatusSeeOther, (<-edited).Code) + + w := <-downloaded + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, + w.Header().Get("Content-Disposition"), "archive-seeded-renamed-", + ) +} + +// stalledWriter is a response writer whose first write waits until +// resume is closed, closing writing when it starts to wait. +type stalledWriter struct { + *httptest.ResponseRecorder + + once sync.Once + writing chan struct{} + resume chan struct{} +} + +func (s *stalledWriter) Write(b []byte) (int, error) { + s.once.Do(func() { + close(s.writing) + <-s.resume + }) + + return s.ResponseRecorder.Write(b) +} + +// startStalledDownload starts a download of the target and returns once +// it is stalled at its first write, which comes before it opens any +// archive file. Closing the writer's resume lets it go on; the returned +// channel is closed when it has finished. +func startStalledDownload( + t *testing.T, env *sourceTestEnv, webhookID, targetID string, +) (*stalledWriter, <-chan struct{}) { + t.Helper() + + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, downloadPath(webhookID, targetID), nil, + ) + for _, c := range env.cookies { + req.AddCookie(c) + } + + sw := &stalledWriter{ + ResponseRecorder: httptest.NewRecorder(), + writing: make(chan struct{}), + resume: make(chan struct{}), + } + downloaded := make(chan struct{}) + + go func() { + targetRouter(env).ServeHTTP(sw, req) + close(downloaded) + }() + + <-sw.writing + + return sw, downloaded +} + +// TestHandleTargetDownload_StreamsWithoutTheLock proves a download +// lets go of the rename lock once it has listed its archive: while the +// download is stalled writing, an edit can still rename the target. +func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID) + + edited := make(chan *httptest.ResponseRecorder, 1) + + go func() { + edited <- renameTarget(env, wh.ID, archive.ID) + }() + + select { + case w := <-edited: + assert.Equal(t, http.StatusSeeOther, w.Code) + case <-time.After(10 * time.Second): + t.Error("the rename waited for the download") + } + + close(sw.resume) + <-downloaded + assert.Equal(t, http.StatusOK, sw.Code) +} + +// TestHandleTargetDownload_FindsFilesAfterRename proves a download +// finds each of the target's files again under the names stored when +// it reaches the file: the target is renamed while the download is +// stalled before it has opened any file, and the rows of both its files +// are in the download. +func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive) + + month := func(path string) string { + return strings.TrimSuffix(path, ".db") + "-2026-10.db" + } + + seedArchive(t, oldPath, 1, 16) + seedArchive(t, month(oldPath), 1, 16) + + sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID) + + require.Equal(t, + http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code, + ) + + // The test's archives record a rename without moving any file, so + // the files are moved here, as the delivery engine moves them. + var renamed database.Target + + require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error) + + newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed) + + require.NoError(t, os.Rename(oldPath, newPath)) + require.NoError(t, os.Rename(month(oldPath), month(newPath))) + + close(sw.resume) + <-downloaded + require.Equal(t, http.StatusOK, sw.Code) + + zr, err := gzip.NewReader(sw.Body) + require.NoError(t, err) + + var ( + got map[string]json.RawMessage + events []map[string]any + ) + + require.NoError(t, json.NewDecoder(zr).Decode(&got)) + require.NoError(t, json.Unmarshal(got["archived_events"], &events)) + require.Len(t, events, 2) + assert.NotContains(t, events[0], "period") + assert.Equal(t, "2026-10", events[1]["period"]) +} + +// seedArchive writes rows to the archive file at path, each with a +// body of bodySize random bytes, which do not compress. Its table has +// only the columns the test fills; an export writes the others empty. +func seedArchive(t *testing.T, path string, rows, bodySize int) { + t.Helper() + + db, err := database.OpenSQLite(path, database.SQLiteModeCreate) + require.NoError(t, err) + + defer func() { require.NoError(t, db.Close()) }() + + _, err = db.ExecContext(t.Context(), + "CREATE TABLE archived_events (id INTEGER PRIMARY KEY, body TEXT)", + ) + require.NoError(t, err) + + body := make([]byte, bodySize) + + for range rows { + _, _ = rand.Read(body) + + _, err = db.ExecContext(t.Context(), + "INSERT INTO archived_events (body) VALUES (?)", string(body), + ) + require.NoError(t, err) + } +} + +// limitedServer serves the target routes as the server does, behind the +// access log, whose lines it returns, and the request limit, here +// limit, which is also its write timeout. Each connection's send buffer +// is a few KiB, so a larger response is still being written while its +// client is not reading. +func limitedServer( + t *testing.T, env *sourceTestEnv, limit time.Duration, +) (*httptest.Server, *bytes.Buffer) { + t.Helper() + + const sendBuffer = 4 << 10 + + logBuf := new(bytes.Buffer) + mw := middlewaretest.New( + t, + slog.New(slog.NewJSONHandler(logBuf, nil)), + &config.Config{Environment: config.EnvironmentDev}, + nil, + ) + + srv := httptest.NewUnstartedServer( + mw.Logging()(mw.Timeout(limit)(targetRouter(env))), + ) + srv.Config.WriteTimeout = limit + srv.Config.ConnContext = func( + ctx context.Context, c net.Conn, + ) context.Context { + tcp, ok := c.(*net.TCPConn) + if assert.True(t, ok) { + assert.NoError(t, tcp.SetWriteBuffer(sendBuffer)) + } + + return ctx + } + srv.Start() + t.Cleanup(srv.Close) + + return srv, logBuf +} + +// TestHandleTargetDownload_OutlastsTheRequestLimit proves a download +// runs for as long as the client keeps reading, and is logged as the +// 200 it was. Behind a request limit and a server write timeout of a +// tenth of a second, the client stops reading once the response has +// started, waits three times as long, and still gets the whole file. +// The archive is larger than the connection holds, so the download is +// still being written while the client waits. +func TestHandleTargetDownload_OutlastsTheRequestLimit(t *testing.T) { + t.Parallel() + + const ( + limit = 100 * time.Millisecond + rows = 8 + bodySize = 64 << 10 + ) + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + seedArchive( + t, delivery.ArchivePath(env.dbMgr, &wh, archive), rows, bodySize, + ) + + srv, accessLog := limitedServer(t, env, limit) + + req, err := http.NewRequestWithContext( + t.Context(), http.MethodGet, + srv.URL+downloadPath(wh.ID, archive.ID), nil, + ) + require.NoError(t, err) + + for _, c := range env.cookies { + req.AddCookie(c) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + time.Sleep(3 * limit) + + zr, err := gzip.NewReader(resp.Body) + require.NoError(t, err) + + var ( + got map[string]json.RawMessage + events []json.RawMessage + ) + + require.NoError(t, json.NewDecoder(zr).Decode(&got)) + require.NoError(t, json.Unmarshal(got["archived_events"], &events)) + assert.Len(t, events, rows) + + // Reading to the end makes the gzip reader check that the file was + // finished. + _, err = io.ReadAll(zr) + require.NoError(t, err) + + // Close waits for the handler, so the access log line is written. + srv.Close() + + var access map[string]any + + require.NoError(t, json.Unmarshal(accessLog.Bytes(), &access)) + assert.EqualValues(t, http.StatusOK, access["status"]) + assert.GreaterOrEqual(t, + access["latency_ms"], float64(limit.Milliseconds()), + "the download must outlast the request limit", + ) +} + +// brokenWriter is a response writer whose writes fail once the +// response has started, as they do when the client goes away. +type brokenWriter struct { + *httptest.ResponseRecorder +} + +func (b brokenWriter) Write(p []byte) (int, error) { + if b.Body.Len() > 0 { + return 0, errClientGone + } + + return b.ResponseRecorder.Write(p) +} + +// TestHandleTargetDownload_AbortsWhenItFails proves a download that +// fails after its response has started aborts the connection, so the +// client sees a failed download rather than a file that looks +// complete and does not decompress. +func TestHandleTargetDownload_AbortsWhenItFails(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 7) + archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil, + ) + for _, c := range env.cookies { + req.AddCookie(c) + } + + w := brokenWriter{ResponseRecorder: httptest.NewRecorder()} + + assert.PanicsWithValue(t, http.ErrAbortHandler, func() { + targetRouter(env).ServeHTTP(w, req) + }) + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 8ff03b5..9a29fb4 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -1,7 +1,9 @@ package handlers import ( + "errors" "net/http" + "strconv" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/database" @@ -12,10 +14,13 @@ import ( const targetEditTemplate = "target_edit.html" // tmplKeyTarget is the template data key for the target being -// edited, and tmplKeyMaxTimeout for the timeout ceiling the form -// tells the user about. +// edited, tmplKeyTargetForm for the values its form shows, and +// tmplKeyMaxTimeout for the timeout ceiling the form tells the user +// about. The add target form on the webhook page takes its values +// under the same key as the edit form. const ( tmplKeyTarget = "Target" + tmplKeyTargetForm = "TargetForm" tmplKeyMaxTimeout = "MaxTimeout" ) @@ -27,27 +32,26 @@ const configUnreadableMessage = "The stored configuration for this " + "target could not be read. Enter the values below; saving " + "replaces the stored configuration." -// targetEditView is the display model for the target edit page. +// targetEditView is the display model for the target edit page: the +// target's row fields as stored. The values the form shows, the +// UNMASKED configuration among them, come separately, as a +// targetFormInput. // -// It carries the target's row fields alongside its UNMASKED -// configuration, and deliberately omits database.Target's raw -// Config blob: the form renders named fields, and giving the -// template the blob as well would put an unreviewed second path to -// the credential on the page. +// It deliberately omits database.Target's raw Config blob: the form +// renders named fields, and giving the template the blob as well +// would put an unreviewed second path to the credential on the page. type targetEditView struct { - ID string - Name string - Type database.TargetType - Active bool - MaxRetries int - Config delivery.TargetConfigForm + ID string + Name string + Type database.TargetType + Active bool } // HandleTargetEdit shows the form to edit a target. // // This page is the one place the full destination URL and header // values are shown. It is reachable only through the -// /source/{sourceID} route group, which supplies RequireAuth and +// /hook/{sourceID} route group, which supplies RequireAuth and // NoCache, and only for a target of a webhook the session's user // owns; masking (delivery.TargetView) is unchanged everywhere else. func (h *Handlers) HandleTargetEdit() http.HandlerFunc { @@ -72,13 +76,29 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { msg = configUnreadableMessage } - h.renderTargetEdit(w, r, webhook, target, cfg, msg) + form := targetFormInput{ + Name: target.Name, + URL: cfg.URL, + Headers: cfg.Headers, + Timeout: cfg.Timeout, + ForwardQuery: cfg.ForwardQuery, + MaxRetries: strconv.Itoa(target.MaxRetries), + Expiry: cfg.Expiry, + Rotation: cfg.Rotation, + } + + h.renderTargetEdit( + w, r, webhook, target, form, msg, http.StatusOK, + ) } } // HandleTargetEditSubmit handles the target edit form submission. func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + h.renameMu.Lock() + defer h.renameMu.Unlock() + webhook, target, ok := h.ownedTarget(w, r) if !ok { return @@ -88,9 +108,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { - http.Error( - w, "Bad request", http.StatusBadRequest, - ) + h.renderError(w, r, http.StatusBadRequest) return } @@ -99,11 +117,12 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { } } -// applyTargetEdit validates and saves target edits. +// applyTargetEdit validates and saves target edits. A refused save +// shows the edit form again with the values submitted and the reason. // -// The submitted configuration goes through buildTargetConfig, the -// same builder the create path uses, so an edited destination is -// SSRF-validated exactly as a new one is. +// The submission goes through setTargetFromForm, as a new target +// does, so an edited destination is SSRF-validated exactly as a new +// one is. // // The target's type is not editable. Each type stores a different // configuration shape and its delivery history is recorded against @@ -116,66 +135,99 @@ func (h *Handlers) applyTargetEdit( webhook database.Webhook, target *database.Target, ) { - name := r.PostFormValue("name") - if name == "" { - http.Error( - w, "Name is required", http.StatusBadRequest, + in := targetFormInputFrom(r) + + // edited is the target as the submission leaves it; target stays + // as stored, for the page shown again when the save is refused. + edited := *target + + errMsg, err := h.setTargetFromForm(r.Context(), &edited, in) + if err != nil { + h.serverError(w, r, "failed to encode target config", err) + + return + } + + if errMsg != "" { + h.renderTargetEdit( + w, r, webhook, target, in, errMsg, http.StatusBadRequest, ) return } - configJSON, err := h.buildTargetConfig( - w, r, target.Type, targetFormInputFrom(r), + // A new name renames the archive file before it is saved (see + // delivery.Engine.Rename). If either step fails, it goes back to + // the name that is still stored. + err = h.renameTargetArchive( + target, webhook.Name, target.Name, edited.Name, ) - if err != nil { - // buildTargetConfig has already written the response. - return + if err == nil { + err = h.db.DB().Save(&edited).Error } - // Retries are offered only by the forms for target types that - // retry, so an absent field means "this form does not edit - // retries" rather than "set them to zero". Reading it - // unconditionally would silently disable retries on any target - // saved from a form that does not render the input. - // - // A field that IS submitted but does not parse is a 400, through - // the same validator the create path uses. It is rejected before - // anything is written, so a typo cannot destroy the retry count - // the target is already delivering with. - if r.PostForm.Has("max_retries") { - retries, ok := targetMaxRetries(w, r, target.MaxRetries) - if !ok { + if err != nil { + restoreErr := h.renameTargetArchive( + target, webhook.Name, edited.Name, target.Name, + ) + if restoreErr != nil { + h.log.Error( + "failed to rename archive back", + "target_id", target.ID, + "error", restoreErr, + ) + } + + if errors.Is(err, delivery.ErrArchiveNameTaken) { + h.renderTargetEdit( + w, r, webhook, target, in, + "Not saved: "+err.Error()+ + ". Move that archive out of the data directory, "+ + "its .db together with any -wal and -shm beside "+ + "it, then save again.", + http.StatusConflict, + ) + return } - target.MaxRetries = retries - } - - target.Name = name - target.Config = configJSON - - err = h.db.DB().Save(target).Error - if err != nil { - h.serverError(w, "failed to update target", err) + h.serverError(w, r, "failed to update target", err) return } http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, withNotice("/hook/"+webhook.ID, targetSaved), + http.StatusSeeOther, ) } -// renderTargetEdit renders the target edit page with an optional -// error message. +// renameTargetArchive renames a database target's archive file from +// the target name oldName to newName. It does nothing when the name +// is unchanged; other target types have no archive. +func (h *Handlers) renameTargetArchive( + target *database.Target, + webhookName, oldName, newName string, +) error { + if h.archives == nil || oldName == newName || + target.Type != database.TargetTypeDatabase { + return nil + } + + return h.archives.Rename(target.ID, webhookName, newName) +} + +// renderTargetEdit renders the target edit page for the target as +// stored, its form showing form's values, with an optional error +// message above it. func (h *Handlers) renderTargetEdit( w http.ResponseWriter, r *http.Request, webhook database.Webhook, target *database.Target, - cfg delivery.TargetConfigForm, + form targetFormInput, errMsg string, + status int, ) { // The template calls Webhook methods, which take pointer // receivers; html/template cannot address a value stored in a @@ -183,18 +235,21 @@ func (h *Handlers) renderTargetEdit( data := map[string]any{ tmplKeyWebhook: &webhook, tmplKeyTarget: targetEditView{ - ID: target.ID, - Name: target.Name, - Type: target.Type, - Active: target.Active, - MaxRetries: target.MaxRetries, - Config: cfg, + ID: target.ID, + Name: target.Name, + Type: target.Type, + Active: target.Active, }, - tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds, - tmplKeyError: errMsg, + tmplKeyTargetForm: form, + tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds, + tmplKeyError: errMsg, + tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry), + tmplKeyArchiveRotationChoices: archiveRotationOptions( + form.Rotation, + ), } - h.renderTemplate(w, r, targetEditTemplate, data) + h.renderTemplateStatus(w, r, targetEditTemplate, data, status) } // ownedTarget resolves the request's sourceID and targetID @@ -220,7 +275,7 @@ func (h *Handlers) ownedTarget( chi.URLParam(r, "targetID"), webhook.ID, ).First(&target).Error if err != nil { - http.NotFound(w, r) + h.renderError(w, r, http.StatusNotFound) return database.Webhook{}, nil, false } diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 64a93e4..f73aada 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -37,20 +37,24 @@ const ( editAuthHeader = "Authorization: Bearer " + editBearerSecret ) -// targetRouter mounts the target create and edit routes on a chi -// router so the handlers see the URL parameters they read. +// targetRouter mounts the target create, edit and download routes on +// a chi router so the handlers see the URL parameters they read. func targetRouter(env *sourceTestEnv) *chi.Mux { router := chi.NewRouter() router.Post( - "/source/{sourceID}/targets", + "/hook/{sourceID}/targets", env.handlers.HandleTargetCreate(), ) router.Get( - "/source/{sourceID}/targets/{targetID}/edit", + "/hook/{sourceID}/targets/{targetID}/download", + env.handlers.HandleTargetDownload(), + ) + router.Get( + "/hook/{sourceID}/targets/{targetID}/edit", env.handlers.HandleTargetEdit(), ) router.Post( - "/source/{sourceID}/targets/{targetID}/edit", + "/hook/{sourceID}/targets/{targetID}/edit", env.handlers.HandleTargetEditSubmit(), ) @@ -117,7 +121,7 @@ func seedHTTPTarget( w := serveTarget( env, http.MethodPost, - "/source/"+webhook.ID+"/targets", form, + "/hook/"+webhook.ID+"/targets", form, ) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) @@ -188,7 +192,7 @@ func submitTargetEdit( ) *httptest.ResponseRecorder { return serveTarget( env, http.MethodPost, - "/source/"+webhookID+"/targets/"+targetID+"/edit", + "/hook/"+webhookID+"/targets/"+targetID+"/edit", form, ) } @@ -401,7 +405,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked( w := serveTarget( env, http.MethodGet, - "/source/"+webhook.ID+"/targets/"+target.ID+"/edit", + "/hook/"+webhook.ID+"/targets/"+target.ID+"/edit", nil, ) require.Equal(t, http.StatusOK, w.Code) @@ -414,6 +418,83 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked( assert.Contains(t, page, "original-name") } +// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the +// edit page of a database target gives its type and its settings to +// the ones its badge and the add target form use. +func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase) + + w := serveTarget( + env, http.MethodGet, + "/hook/"+webhook.ID+"/targets/"+target.ID+"/edit", + nil, + ) + require.Equal(t, http.StatusOK, w.Code) + + page := w.Body.String() + + assert.Contains(t, page, "Type: archive.") + assert.Contains(t, page, `class="label">Archive expiry`) + assert.Contains(t, page, `class="label">Archive rotation`) +} + +// TestHandleTarget_ForwardQuery covers the HTTP target's setting that +// passes each event's query string on to it: the add target form +// stores it checked, the edit form starts with it checked and turns it +// off when saved unchecked, and both forms come back with it checked +// when refused. +func TestHandleTarget_ForwardQuery(t *testing.T) { + t.Parallel() + + const checkbox = `name="forward_query" value="on" checked` + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + targetsPath := "/hook/" + webhook.ID + "/targets" + + form := url.Values{} + form.Set("name", "forwarding") + form.Set("type", string(database.TargetTypeHTTP)) + form.Set("url", editOriginalURL) + form.Set("forward_query", "on") + + w := serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + w = serveTarget( + env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil, + ) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + edit := editForm(editOriginalURL, "", "") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + edit.Set("url", editBlockedURL) + edit.Set("forward_query", "on") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + form.Set("url", editBlockedURL) + + w = serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "data-forward-query") +} + // TestHandleTargetEditSubmit_Rejects covers every submission that // must not reach storage. // @@ -508,7 +589,7 @@ func assertEditIgnoresQueryString( w := serveTarget( env, http.MethodPost, - "/source/"+webhook.ID+"/targets/"+target.ID+ + "/hook/"+webhook.ID+"/targets/"+target.ID+ "/edit?url="+url.QueryEscape(editReplacedURL)+ "&headers="+url.QueryEscape(editAuthHeader), form, @@ -561,6 +642,78 @@ func assertEditRejectsTimeout( ) } +// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of +// a target of each type and checks that the edit form comes back with +// the reason and every value submitted, and that nothing is saved. +func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + // fields is what the operator submitted, as a query string. + cases := []struct { + targetType database.TargetType + fields string + reason string + }{ + { + database.TargetTypeHTTP, + "name=edited&url=" + editBlockedURL + + "&headers=X-Edited:+kept&timeout=12&max_retries=3", + "Invalid target URL", + }, + { + database.TargetTypeSlack, + "name=edited&url=" + editOriginalURL + "&max_retries=25", + "Invalid delivery attempts", + }, + { + database.TargetTypeDatabase, + "name=edited&expiry=7d&rotation=daily", + "Invalid archive expiry", + }, + {database.TargetTypeLog, "name=", "Name is required"}, + } + + for _, tc := range cases { + t.Run(string(tc.targetType), func(t *testing.T) { + t.Parallel() + + webhook := seedWebhookWithRetention(t, env.db, 30) + target := seedTarget(t, env.db, webhook.ID, tc.targetType) + + form, err := url.ParseQuery(tc.fields) + require.NoError(t, err) + + w := submitTargetEdit(env, webhook.ID, target.ID, form) + assert.Equal(t, http.StatusBadRequest, w.Code) + + page := w.Body.String() + assert.Contains(t, page, `class="alert-error">`+tc.reason) + + // headers is the form's one textarea, and expiry and + // rotation its selects; every other field is an input. + for field := range form { + shown := `name="` + field + `" value="` + form.Get(field) + `"` + + switch field { + case "headers": + shown = ">" + form.Get(field) + "" + case "expiry", "rotation": + shown = `