Compare commits
1 Commits
b261a52515
...
c7bf648526
| Author | SHA1 | Date | |
|---|---|---|---|
| c7bf648526 |
@@ -19,14 +19,9 @@ RUN go mod download
|
|||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter. golangci-lint is invoked directly rather
|
# Run formatting check and linter
|
||||||
# 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
|
RUN make fmt-check
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN make lint
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
@@ -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
114
README.md
@@ -12,16 +12,14 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for linting, for the test stage of the CI gate, and for
|
- golangci-lint v2.12.2 (the version pinned in `script/bootstrap` and
|
||||||
containerized deployment)
|
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
|
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||||
browser assets, which are not committed (`make bootstrap` installs
|
browser assets, which are not committed (`make bootstrap` installs
|
||||||
it if missing)
|
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
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -29,9 +27,9 @@ digest-pinned linter image via `Dockerfile.lint`.
|
|||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
cd webhooker
|
cd webhooker
|
||||||
|
|
||||||
# Install Go dependencies and the third-party browser assets.
|
# Install Go dependencies, the pinned linter, and the third-party
|
||||||
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
# browser assets. `make deps` alone is not enough: it only runs
|
||||||
# and the checks below need the fetched assets.
|
# go mod download/tidy, and the checks below need the fetched assets.
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check)
|
# 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 assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
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 test # Run tests with race detection
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker
|
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
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
`static/`, verifying each against its pinned sha256
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `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` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `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
|
is under half a megabyte and does not grow with the number of
|
||||||
usernames an attacker invents.
|
usernames an attacker invents.
|
||||||
3. **Concurrent password verifications are capped at two, and the
|
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
|
request costs an Argon2id hash, and Argon2id here is 64 MB per
|
||||||
hash — two slots is a 128 MB ceiling on password hashing. Every
|
hash — two slots is a 128 MB ceiling on password hashing. Every
|
||||||
endpoint that hashes a password takes a slot, including the
|
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
|
verification and the new hash. A request that waits five seconds
|
||||||
without getting a slot is answered `503 Service Unavailable` and no
|
without getting a slot is answered `503 Service Unavailable` and no
|
||||||
hash is computed for it. The wait alone does not bound memory, only
|
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
|
how long one request holds some: a waiter reaches the guard with
|
||||||
as well. Size the queue from what a parked waiter actually retains,
|
its form already parsed, so it holds up to the 1 MB body cap for as
|
||||||
not from the 1 MB body cap: that caps the raw body read, while the
|
long as it waits, and at flood rates an unbounded queue would be
|
||||||
body-cap, CSRF and form-parsing middleware all run before the
|
worth gigabytes against a 128 MB hashing budget. So the number of
|
||||||
guard, so a waiter holds its parsed form plus its request header
|
waiters is capped as well, at 64 — 1 MB each against 64 MB of
|
||||||
block for the whole wait. Measured on the pinned Go 1.26.1
|
committed queue memory, and shallow enough that two slots can drain
|
||||||
toolchain, as the heap delta with 64 waiters parked in the handler,
|
a full queue inside the five-second deadline. A request arriving
|
||||||
an ordinary two-field login form retains ~0 MB, a 1 MB urlencoded
|
past the cap is shed with `503` immediately instead of joining the
|
||||||
body at Go's 10,000-parameter parse cap retains 2.82 MB (3.09 MB
|
queue. Peak commitment for the endpoint is therefore 128 MB of
|
||||||
with `%41` escapes), and the ~0.9 MB of headers the 1 MB header cap
|
hashing plus about 66 MB of parsed request bodies.
|
||||||
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.
|
|
||||||
|
|
||||||
An unknown username is verified against a dummy hash rather than
|
An unknown username is verified against a dummy hash rather than
|
||||||
rejected early, so a nonexistent account costs the same time as a real
|
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 |
|
| Method | Path | Description |
|
||||||
| ------ | --------------- | ----------- |
|
| ------ | --------------- | ----------- |
|
||||||
| `GET` | `/pages/login` | Login page (not rate limited) |
|
| `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) |
|
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||||
|
|
||||||
#### Authenticated Endpoints
|
#### Authenticated Endpoints
|
||||||
@@ -1229,7 +1217,7 @@ abuse limit later; they are tracked as future work.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `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` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/sources/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/sources/new` | Create webhook submission |
|
||||||
@@ -1351,7 +1339,6 @@ webhooker/
|
|||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── 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
|
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .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
|
no shutdown diagnostics at all. Keep the deployment's grace above
|
||||||
the stop timeout.
|
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
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a three-stage build. Each stage is pinned by
|
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) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check`, then `golangci-lint config verify` and
|
`make fmt-check` then `make lint`.
|
||||||
`golangci-lint run`, both with `--network=none`.
|
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the 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`
|
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||||
to download and verify the third-party browser assets, then runs
|
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,
|
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
||||||
and includes a health check against `/.well-known/healthcheck`.
|
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
|
Both check stages use Debian rather than Alpine because
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||||
and does not compile against musl. Only the final binary is statically
|
and does not compile against musl. Only the final binary is statically
|
||||||
linked, which is what lets it run on the Alpine runtime image.
|
linked, which is what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
`script/cibuild` — `docker build .` — is the CI gate: the four check
|
||||||
inside the image, so a build that succeeds is a repo that is formatted,
|
targets run inside the image, so a build that succeeds is a repo that
|
||||||
linted, tested and compiled. `script/lint` also uses Docker
|
is formatted, linted, tested and compiled. Only `script/cibuild` and
|
||||||
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
`script/docker` involve Docker. `script/lint`, and therefore
|
||||||
run the same pinned linter version the gate does; only `script/test`
|
`make lint` and `make check`, run whatever `golangci-lint` is on the
|
||||||
and `script/fmt-check` run on the host.
|
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
|
#### 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
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
`make fmt-check`, `make lint`, `make test`, and `make build`. A run
|
||||||
run that reports success ran them.
|
that reports success ran them.
|
||||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||||
anyway — so the image replays from cache and costs seconds.
|
anyway — so the image replays from cache and costs seconds.
|
||||||
|
|||||||
@@ -51,33 +51,24 @@ const (
|
|||||||
// The wait bounds how long one request occupies memory; this
|
// The wait bounds how long one request occupies memory; this
|
||||||
// bounds how many do so at the same time, and without it the
|
// 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
|
// 128 MB hashing budget above is the smaller half of the real
|
||||||
// footprint. At the 400 req/s a saturation attack can offer, an
|
// footprint. A waiter is not free: by the time it reaches the
|
||||||
// unbounded queue would park ~2000 requests for the full five
|
// guard its form is parsed, so it holds up to maxFormBodySize —
|
||||||
// seconds.
|
// 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
|
// Arithmetic: 1 MB a waiter, and the memory committed to the
|
||||||
// caps the raw body read, not what the parse retains. MaxBodySize,
|
// queue is 64 MB, so 64 waiters. Cross-check against the
|
||||||
// CSRF and ParseForm all run before acquire, so a parked waiter
|
// deadline: two slots at the ~27 verifications/s measured on a
|
||||||
// holds r.Form plus r.PostForm plus its header block for the
|
// review host (with the race detector on, so the real rate is
|
||||||
// whole wait. Measured on the pinned go1.26.1 toolchain, as the
|
// higher) drain a full 64-deep queue in about 2.4 s, inside
|
||||||
// HeapAlloc delta across two GCs with 64 waiters parked in the
|
// passwordVerifyWait. Queueing deeper would buy memory rather
|
||||||
// handler: an ordinary two-field login form retains ~0 MB, but a
|
// than throughput, because the extra waiters could not be served
|
||||||
// 1 MB urlencoded body at Go's 10,000-parameter parse cap retains
|
// before their deadline anyway.
|
||||||
// 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, from the measured 4.18 MB worst case: 16 waiters
|
// Peak commitment is therefore 128 MB of Argon2id plus at most
|
||||||
// commit ~67 MB of queue memory, and peak commitment for the
|
// 66 MB of parsed forms — 64 queued and the 2 being hashed.
|
||||||
// endpoint is 128 MB of Argon2id plus the 18 requests that retain
|
passwordVerifyMaxWaiters = 64
|
||||||
// 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
|
|
||||||
|
|
||||||
// failureKeyHashBytes is how much of the username digest goes
|
// failureKeyHashBytes is how much of the username digest goes
|
||||||
// into a failure key. 64 bits over at most loginFailureMaxKeys
|
// into a failure key. 64 bits over at most loginFailureMaxKeys
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||||
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
# script/fetch-assets, which installs the hash-pinned third-party browser
|
# curl | sh). Finishes by running script/fetch-assets, which installs the
|
||||||
# assets the repo does not commit.
|
# hash-pinned third-party browser assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -51,6 +57,52 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! 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() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -58,14 +110,9 @@ main() {
|
|||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; 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
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
ensure_golangci_lint
|
||||||
# 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
|
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
|||||||
47
script/lint
47
script/lint
@@ -1,55 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter. golangci-lint is never installed locally: it
|
# script/lint: run the linter.
|
||||||
# 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.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
golangci-lint run --config .golangci.yml ./...
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user