Compare commits

1 Commits

Author SHA1 Message Date
c7bf648526 Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is
required to run behind, every login POST keyed on the proxy's address
and shared one 5/minute bucket. A stranger sending five POSTs a
minute -- 0.08 requests per second, from anywhere -- kept that bucket
permanently full, and the operator's own correct password was answered
429 indefinitely with no second administrative path.

The login POST no longer has a pre-emptive limiter. The handler
verifies credentials first and spends budget only on a FAILED attempt,
so a correct password is never throttled whatever the counters hold.
Three things follow, and are implemented together because the first is
unsafe without the other two:

- Failures are counted per (client bucket, submitted username), five
  per minute, after which further failures get 429 with a Retry-After.
  A successful login clears the counter, so mistyping and then
  succeeding does not leave the operator throttled.
- Both key sets are capped at 1024 entries. The submitted username is
  attacker-controlled, so past the first cap failures fall back to a
  counter keyed on the client alone, and past both caps a failure is
  answered as throttled without being recorded. Tracked state stays
  under half a megabyte and does not grow with invented usernames.
- Concurrent Argon2id verifications are capped at two, a 128 MB
  ceiling at 64 MB per hash, and the queue for those slots is capped
  at 64 waiters. Every password-hashing endpoint takes a slot,
  including the password-change endpoint, which holds one across both
  its hashes. A request that waits five seconds without a slot is
  answered 503, and one that arrives with the queue already full is
  shed with 503 immediately rather than joining it. Bounding the wait
  alone would not bound memory: a waiter reaches the guard with its
  form parsed, so it holds up to the 1 MB body cap for the whole wait,
  and at flood rates an unbounded queue is worth gigabytes against a
  128 MB hashing budget. 64 waiters is 64 MB of committed queue
  memory, shallow enough that two slots drain a full queue inside the
  five-second deadline; peak commitment is 128 MB of hashing plus
  about 66 MB of parsed bodies.

An unknown username is verified against a dummy hash instead of
returning early, so a nonexistent account costs the same time as a
real one and the response cannot be used to enumerate usernames.

The password-change limiter is unchanged: RequireAuth runs ahead of
it, so only a request already carrying a valid session reaches its
bucket.

Two consequences are documented rather than fixed, because they follow
from the shape the issue asks for. Online guessing throughput rises
from 5 a minute to roughly 27 a second, about 2.3 million a day: the
credential check always precedes the counter, so the 429 is a label on
the response rather than a gate in front of the hash, and what bounds
brute force is the semaphore. And under a sustained flood the residual
exposure is a loss of login availability, not merely of latency --
above about 27 requests a second most attempts are shed with 503, so a
determined flood still denies login for as long as it runs. It costs
roughly 400x more to run, nothing accumulates, and the first attempt
after it stops succeeds. Restarting the service does not help: the
counters a restart clears are not what is saturated.

Also adds the missing test for the third bucketKey call site, where
the peer is a trusted proxy but the forwarded chain names no client.
Every existing test of that fallback uses an IPv4 proxy, where
bucketKey is the identity function, so dropping the /64 masking there
left the suite green.

README and the TRUSTED_PROXIES startup warning updated: a shared
bucket now costs precision, not the availability of the admin path.
2026-08-17 22:56:01 +00:00
6 changed files with 112 additions and 205 deletions

View File

@@ -19,14 +19,9 @@ RUN go mod download
# .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 formatting check and linter
RUN make fmt-check
RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./...
RUN make lint
# Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17

View File

@@ -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 ./...

114
README.md
View File

@@ -12,16 +12,14 @@ with retry support, logging, and observability. Category: infrastructure
### 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)
- golangci-lint v2.12.2 (the version pinned in `script/bootstrap` and
in the `Dockerfile`'s lint stage; `make bootstrap` installs it)
- Docker (for containerized deployment, and for the lint and test
stages of the CI gate)
- `curl`, used by `script/fetch-assets` to download the third-party
browser assets, which are not committed (`make bootstrap` installs
it if missing)
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`.
### Quick Start
```bash
@@ -29,9 +27,9 @@ 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 Go dependencies, the pinned linter, 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.
make bootstrap
# Run all checks (test, lint, format check)
@@ -54,7 +52,7 @@ 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 lint # Run golangci-lint
make test # Run tests with race detection
make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker
@@ -279,7 +277,7 @@ are inline commands with no script behind them. We provide:
- `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/lint` — run golangci-lint
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
@@ -1130,7 +1128,7 @@ second administrative path. So the handler inverts the order:
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
queue for them at 64.** 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
@@ -1138,26 +1136,16 @@ second administrative path. So the handler inverts the order:
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. Provision for that figure, not for the
hashing budget alone.
how long one request holds some: a waiter reaches the guard with
its form already parsed, so it holds up to the 1 MB body cap for as
long as it waits, and at flood rates an unbounded queue would be
worth gigabytes against a 128 MB hashing budget. So the number of
waiters is capped as well, at 64 — 1 MB each against 64 MB of
committed queue memory, and shallow enough that two slots can drain
a full queue 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 128 MB of
hashing plus about 66 MB of parsed request bodies.
An unknown username is verified against a dummy hash rather than
rejected early, so a nonexistent account costs the same time as a real
@@ -1221,7 +1209,7 @@ abuse limit later; they are tracked as future work.
| 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/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 64 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
| `POST` | `/pages/logout` | Logout (destroys session) |
#### Authenticated Endpoints
@@ -1229,7 +1217,7 @@ abuse limit later; they are tracked as future work.
| 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) |
| `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 64 requests are already queued for one) |
| `GET` | `/sources` | List user's webhooks |
| `GET` | `/sources/new` | Create webhook form |
| `POST` | `/sources/new` | Create webhook submission |
@@ -1351,7 +1339,6 @@ webhooker/
├── 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 16 targets shim script/; 6 are inline
├── go.mod / go.sum
└── .golangci.yml # Linter configuration
@@ -1572,37 +1559,6 @@ Two operational consequences follow from bounding the sequence:
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.
Three 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.
### Docker
The Dockerfile uses a three-stage build. Each stage is pinned by
@@ -1611,8 +1567,7 @@ 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`.
`make fmt-check` then `make lint`.
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
@@ -1623,21 +1578,20 @@ version is fixed independently of the compiler's:
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
and includes a health check against `/.well-known/healthcheck`.
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.
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.
`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``docker build .` — is the CI gate: the four check
targets run inside the image, so a build that succeeds is a repo that
is formatted, linted, tested and compiled. Only `script/cibuild` and
`script/docker` involve Docker. `script/lint`, and therefore
`make lint` and `make check`, run whatever `golangci-lint` is on the
host, which can be a different version from the pinned one — so the
container is the authoritative lint result
([issue #109](https://git.eeqj.de/sneak/webhooker/issues/109) tracks
routing local linting through it as well).
#### CI gate honesty
@@ -1650,8 +1604,8 @@ the hash of the last commit that touched the build context, so:
- 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.
`make fmt-check`, `make 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.

View File

@@ -51,33 +51,24 @@ const (
// The wait bounds how long one request occupies memory; this
// bounds how many do so at the same time, and without it the
// 128 MB hashing budget above is the smaller half of the real
// footprint. At the 400 req/s a saturation attack can offer, an
// unbounded queue would park ~2000 requests for the full five
// seconds.
// footprint. A waiter is not free: by the time it reaches the
// guard its form is parsed, so it holds up to maxFormBodySize —
// 1 MB — for as long as it waits. At the 400 req/s a saturation
// attack can offer, an unbounded queue would hold ~2000 of those
// for the full five seconds, which is gigabytes.
//
// A waiter costs far more than maxFormBodySize suggests: that
// caps the raw body read, not what the parse retains. MaxBodySize,
// CSRF and ParseForm all run before acquire, so a parked waiter
// holds r.Form plus r.PostForm plus its header block for the
// whole wait. Measured on the pinned go1.26.1 toolchain, as the
// HeapAlloc delta across two GCs with 64 waiters parked in the
// handler: an ordinary two-field login form retains ~0 MB, but a
// 1 MB urlencoded body at Go's 10,000-parameter parse cap retains
// 2.82 MB (3.09 MB with %41 escapes), and adding the ~0.9 MB of
// headers httpMaxHeaderBytes allows takes it to 4.18 MB. The
// retained parse and the header block dominate; the raw body does
// not.
// Arithmetic: 1 MB a waiter, and the memory committed to the
// queue is 64 MB, so 64 waiters. Cross-check against the
// deadline: two slots at the ~27 verifications/s measured on a
// review host (with the race detector on, so the real rate is
// higher) drain a full 64-deep queue in about 2.4 s, inside
// passwordVerifyWait. Queueing deeper would buy memory rather
// than throughput, because the extra waiters could not be served
// before their deadline anyway.
//
// Arithmetic, from the measured 4.18 MB worst case: 16 waiters
// commit ~67 MB of queue memory, and peak commitment for the
// endpoint is 128 MB of Argon2id plus the 18 requests that retain
// a parsed form — 16 queued and the 2 being hashed — at
// 18 * 4.18 MB, so ~75 MB: about 203 MB in all. Cross-check
// against the deadline: two slots at the ~27 verifications/s
// measured on a review host (with the race detector on, so the
// real rate is higher) drain a full 16-deep queue in about 0.6 s,
// far inside passwordVerifyWait.
passwordVerifyMaxWaiters = 16
// Peak commitment is therefore 128 MB of Argon2id plus at most
// 66 MB of parsed forms — 64 queued and the 2 being hashed.
passwordVerifyMaxWaiters = 64
// failureKeyHashBytes is how much of the username digest goes
// into a failure key. 64 bits over at most loginFailureMaxKeys

View File

@@ -3,14 +3,20 @@
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is deliberately not installed: linting runs
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
# script/fetch-assets, which installs the hash-pinned third-party browser
# assets the repo does not commit.
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
# it is installed from a hash-verified GitHub release archive (never
# curl | sh). Finishes by running script/fetch-assets, which installs the
# hash-pinned third-party browser assets the repo does not commit.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
PKGMGR=""
SUDO=""
@@ -51,6 +57,52 @@ missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
main() {
cd "$ROOT"
@@ -58,14 +110,9 @@ main() {
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# Go toolchain
# Go toolchain and linter
if missing go; then pkg_install go golang go go; fi
# Not installed here: docker is platform-specific and out of scope for a
# package-manager bootstrap, but script/lint needs it.
if missing docker; then
echo "bootstrap: docker not found; script/lint requires it" >&2
fi
ensure_golangci_lint
go mod download

View File

@@ -1,55 +1,12 @@
#!/bin/sh
# script/lint: run the linter. golangci-lint is never installed locally: it
# runs via docker only, one way, everywhere — script/lint builds
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
# and lints as a build step. This works even when the docker daemon is remote
# and bind mounts are impossible, and it removes the host linter's shared
# cache, which has attributed other checkouts' findings to this one.
#
# --no-cache-filter=lint forces the lint stage to re-execute on every run; a
# cached lint stage exits 0 in under a second having linted nothing. The deps
# stage keeps its cache, so module downloads are not repeated.
# --progress=plain keeps the linter's own output visible on success, so a
# passing run shows the issue count rather than nothing.
# --output=type=cacheonly leaves no image behind to clean up.
#
# docker silently ignores --no-cache-filter for a stage name that does not
# match, so a rename or a typo would restore the cached false green with no
# warning and a fast exit 0. The flag is therefore not trusted: the build
# output is teed to a log and a run is only a pass if golangci-lint's own
# summary line ("N issues." / "N issues:") is in it. No summary, no lint,
# whatever the exit code says.
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
log="$(mktemp -t webhooker-lint.XXXXXXXX)"
rcfile="$(mktemp -t webhooker-lint-rc.XXXXXXXX)"
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
# The pipeline's status is tee's, and POSIX sh has no pipefail, so the
# build's status travels via a file. Output still streams live.
{
docker build \
-f Dockerfile.lint \
--no-cache-filter=lint \
--progress=plain \
--output=type=cacheonly \
. 2>&1 && echo 0 >"$rcfile" || echo $? >"$rcfile"
} | tee "$log" >&2
rc="$(cat "$rcfile")"
[ "$rc" -eq 0 ] || exit "$rc"
if ! grep -qE '[0-9]+ issues[.:]' "$log"; then
echo "script/lint: golangci-lint printed no summary line; the linter" >&2
echo " did not run. Check that the stage named in --no-cache-filter" >&2
echo " still matches a stage in Dockerfile.lint." >&2
exit 1
fi
golangci-lint run --config .golangci.yml ./...
}
main "$@"