Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a1ecae8d | ||
|
|
f0adeafde3 | ||
|
|
f755c03110 | ||
|
|
4a724130ca | ||
|
|
d4f4ddf51f | ||
|
|
51580a2bc6 | ||
|
|
e0b211f960 | ||
|
|
978eb01b29 | ||
|
|
3cdab97930 | ||
|
|
83740b1de1 | ||
|
|
aeeeca5ea1 | ||
|
|
6ebac4fa71 | ||
|
|
237f131367 | ||
|
|
7ed1588443 | ||
|
|
b051821370 | ||
|
|
39afa69bfc | ||
|
|
888eaf526b | ||
|
|
251cb3d3d3 | ||
|
|
d61d9dc1c1 | ||
|
|
b0a011f6b4 | ||
|
|
5976a4a98f | ||
|
|
b2c9acdaa6 | ||
|
|
af3703d748 | ||
|
|
322d9a6d6b | ||
|
|
b9f7db6901 | ||
|
|
48cf93ec7e | ||
|
|
8d64259283 | ||
|
|
bde32d3ee6 | ||
|
|
62576f6fc6 | ||
|
|
37b59f8822 | ||
|
|
ee2276a912 | ||
|
|
032f265d69 | ||
|
|
65ace2d856 | ||
|
|
5fda446c71 | ||
|
|
763d8f8058 | ||
|
|
fd5966f807 | ||
|
|
0082f216fa | ||
|
|
89f3b984d2 | ||
|
|
a83e8fe654 | ||
|
|
687405993e | ||
|
|
03cd1859d7 |
+28
-2
@@ -61,14 +61,28 @@ RUN script/fetch-assets
|
|||||||
|
|
||||||
# Run tests and build
|
# Run tests and build
|
||||||
RUN make test
|
RUN make test
|
||||||
RUN make build
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
# Rebuild with static linking for Alpine runtime.
|
# Rebuild with static linking for Alpine runtime.
|
||||||
# make build already verified compilation.
|
# make build already verified compilation.
|
||||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||||
# the binary runs on Alpine without glibc.
|
# the binary runs on Alpine without glibc.
|
||||||
RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
|
#
|
||||||
|
# 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"'
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
@@ -95,6 +109,18 @@ USER webhooker
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# The binary defaults BIND_ADDRESS to 127.0.0.1, which is right for a
|
||||||
|
# bare host: the cleartext listener serves the admin UI and the
|
||||||
|
# unauthenticated receiver, so it must not appear on every interface
|
||||||
|
# of a machine that configured nothing. A container is the other case.
|
||||||
|
# Its network namespace is already the isolation boundary, so binding
|
||||||
|
# every address inside it exposes nothing; what decides exposure is
|
||||||
|
# the publish flag, and `-p 127.0.0.1:8080:8080` is the operator's
|
||||||
|
# control there. Shipping the image on loopback would buy no security
|
||||||
|
# and would make the process unreachable through its own published
|
||||||
|
# port.
|
||||||
|
ENV BIND_ADDRESS=0.0.0.0
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.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.
|
||||||
|
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,
|
||||||
|
# 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.
|
||||||
|
GO_LDFLAGS ?=
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
|
|
||||||
@@ -28,7 +46,7 @@ check:
|
|||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/webhooker ./cmd/webhooker
|
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
@@ -40,6 +58,9 @@ deps:
|
|||||||
go mod download
|
go mod download
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
version:
|
||||||
|
@echo $(VERSION)
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
|||||||
@@ -18,71 +18,159 @@ Issue branches do NOT touch this file — the manager maintains it on
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags exist. `main` (4f5ecb1) is a working webhook proxy
|
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||||
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
|
authoritative list, and the only place to read a count or a state of
|
||||||
event retention (#63), the database archiving target (#43), the admin
|
play from. This file records where the project is, not what is in
|
||||||
password change flow (#65), policy compliance (#6), pinned lint tooling
|
flight: a sentence whose truth depends on a branch being unmerged is
|
||||||
(#55), and fail-loud configuration parsing (#80).
|
wrong the moment it merges, and this file has been wrong that way
|
||||||
|
before.
|
||||||
|
|
||||||
`next` is green — verified both by CI and by cache-defeated container
|
The durability defect that held the tag has landed
|
||||||
runs (`docker build --no-cache-filter=lint --no-cache-filter=builder`) —
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
|
||||||
but the **1.0.0 milestone is no longer complete**. It was reopened on
|
Every SQLite handle opens with WAL journaling and a busy timeout, a
|
||||||
2026-08-20 by a code-level deployability audit that ran the service end
|
bookkeeping write that fails leaves its delivery in a recoverable
|
||||||
to end (verdict:
|
state rather than a lying one, and recovery skips a delivery that
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/33#issuecomment-66686).
|
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`.
|
||||||
|
|
||||||
The bar for 1.0 is not "the milestone is empty" but "sneak can deploy
|
Delivery is at-least-once by design, not by accident: a send whose
|
||||||
this and use it in low-volume production". The audit found the gap
|
result row does not land is attempted again, so a receiver can see a
|
||||||
between those two: two instances on one `DATA_DIR` both deliver
|
duplicate. That is deliberate — the alternative is a silent lost
|
||||||
(reproduced), a failed listen leaves a live non-serving process that
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
restart policies never fire on, there is no inbound authentication of
|
to re-file.
|
||||||
any kind, delivery failures render as a bare word with no status code or
|
|
||||||
error, a terminally failed delivery can never be replayed, the SSRF
|
|
||||||
blocklist has no escape hatch so the proxy cannot forward to your own
|
|
||||||
network at all, and target credentials leak into the per-webhook event
|
|
||||||
databases.
|
|
||||||
|
|
||||||
One caveat on reading a green check, narrower than it used to be. A
|
One caveat on reading a green check: a docs-only commit deliberately
|
||||||
docs-only commit deliberately replays from the layer cache (#119), so a
|
replays from the layer cache
|
||||||
green status on such a commit evidences a replay rather than an executed
|
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||||
run; a code commit invalidates the `COPY` layer and genuinely executes.
|
such a commit evidences a replay rather than an executed run. A code
|
||||||
Superseded runs are no longer the hazard they were: before #152 they
|
commit invalidates the `COPY` layer and genuinely executes.
|
||||||
were recorded as `skipped` and rolled up green, and before #119 a warm
|
|
||||||
layer cache let the gate report success without executing anything,
|
|
||||||
replaying the previous build's console log so the lie looked like a real
|
|
||||||
run. Both are fixed. Note: `TODO.md` was deliberately
|
|
||||||
deleted from this repo in f9a9569 (2026-03-01, #6); its content was
|
|
||||||
folded into the README TODO section, which this draft reconstructs as
|
|
||||||
of 2026-07-06.
|
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the reopened 1.0.0 milestone. The milestone PR
|
Clear the rest of the open 1.0.0 milestone
|
||||||
(https://git.eeqj.de/sneak/webhooker/pulls/111) is held: it carries a
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
|
||||||
`WIP: ` prefix, no labels and is assigned to `clawbot`, and it stays
|
Merging `next` into `main` is a separate act from tagging and waits on
|
||||||
that way until the milestone is empty. Correctness first — the
|
neither of those: `next` is kept mergeable at all times, which is the
|
||||||
duplicate-delivery lock and the listen-failure shutdown — then the
|
point of the branch.
|
||||||
operability gaps that make the service usable in production, then the
|
|
||||||
three credential exposures.
|
|
||||||
|
|
||||||
Three items belong to the owner, none of them blocking. #150 was decided
|
|
||||||
by the manager rather than left to stall the queue and is flagged on the
|
|
||||||
issue for reversal if that call was wrong. #112 (whether `Completed
|
|
||||||
Steps` should exist at all, given it once conflicted on every unit) is
|
|
||||||
unanswered; the provisional ruling in force is that issue branches do
|
|
||||||
not touch this file. #198 records that `make test` is past the org 20s
|
|
||||||
target — 46s of test execution inside a 62.8s CI layer — and turns on
|
|
||||||
which quantity the 60s hard cap governs; it is scoped as the improvement
|
|
||||||
bug the 20-60s band requires, and should be milestoned instead if the
|
|
||||||
cap is read as covering the whole invocation.
|
|
||||||
|
|
||||||
After the tag, the largest open cluster is the unmilestoned follow-up
|
|
||||||
backlog these units generated: #183, #184, #185, #190, #191, #193, #198,
|
|
||||||
#211 and #212 (encrypting target config at rest, split out of the
|
|
||||||
credential-leak fix because it needs a key-rotation and re-wrap story).
|
|
||||||
|
|
||||||
# Completed Steps
|
# 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,
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
|
||||||
matching the org-wide backstop. `go test` applies `-timeout` per
|
matching the org-wide backstop. `go test` applies `-timeout` per
|
||||||
package, and `internal/handlers` had grown past the old budget: a
|
package, and `internal/handlers` had grown past the old budget: a
|
||||||
@@ -308,14 +396,13 @@ credential-leak fix because it needs a key-rotation and re-wrap story).
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Manual event redelivery from the web UI — the "Replay" capability the
|
- Delivery status and retry management UI. Replay of a terminally
|
||||||
README describes as planned. No redelivery code exists anywhere in the
|
failed delivery and per-attempt detail already landed
|
||||||
tree; events are stored in full, which is all it would be built on
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
- Delivery status and retry management UI
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||||
plus handler enforcement; global limits must not apply to receiver
|
plus handler enforcement; global limits must not apply to receiver
|
||||||
endpoints)
|
endpoints)
|
||||||
- Webhook signature verification for GitHub and Stripe HMAC formats
|
|
||||||
- API key authentication for programmatic access (APIKey model exists;
|
- API key authentication for programmatic access (APIKey model exists;
|
||||||
Bearer token middleware does not)
|
Bearer token middleware does not)
|
||||||
- REST API v1
|
- REST API v1
|
||||||
@@ -325,9 +412,10 @@ credential-leak fix because it needs a key-rotation and re-wrap story).
|
|||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics dashboard: success rates, response times, volume
|
- Analytics dashboard: success rates, response times, volume
|
||||||
- A remember-me option at login
|
- A remember-me option at login
|
||||||
- Password reset flow for a forgotten password. The authenticated
|
- Password reset flow for a forgotten password over the web. The
|
||||||
password *change* flow already landed on `main` (#65); reset does not
|
authenticated password *change* flow already landed, and a lost
|
||||||
exist
|
password is recoverable from the console with `webhooker resetpw`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
- SNS and S3 delivery targets
|
- SNS and S3 delivery targets
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dotEnvKey is a throwaway variable name these tests write and read,
|
||||||
|
// so they cannot disturb real configuration.
|
||||||
|
const dotEnvKey = "WEBHOOKER_TEST_DISPATCH_VALUE"
|
||||||
|
|
||||||
|
// writeDotEnvInWorkingDir puts contents in a .env file in a fresh
|
||||||
|
// temporary directory and moves the process there.
|
||||||
|
//
|
||||||
|
// The callers are deliberately not parallel and must stay that way:
|
||||||
|
// t.Chdir moves the whole process. Go releases parallel tests only
|
||||||
|
// after every sequential test in the package has finished, so nothing
|
||||||
|
// else runs while these do.
|
||||||
|
func writeDotEnvInWorkingDir(t *testing.T, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, config.DotEnvPath),
|
||||||
|
[]byte(contents), 0o600,
|
||||||
|
))
|
||||||
|
t.Chdir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatch_MalformedDotEnvRefuses pins the second half of the
|
||||||
|
// defect. godotenv applies nothing at all when a file will not parse,
|
||||||
|
// so one mistyped line used to revert every variable in it to its
|
||||||
|
// default and start the server anyway, with no log line naming the
|
||||||
|
// file. The refusal has to arrive before any subcommand runs, which
|
||||||
|
// is why `help` — the one subcommand that touches nothing — is still
|
||||||
|
// refused here.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
|
func TestDispatch_MalformedDotEnvRefuses(t *testing.T) {
|
||||||
|
writeDotEnvInWorkingDir(t, "PORT 19615\n")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := dispatch(
|
||||||
|
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, 1, code, "a broken .env must exit non-zero")
|
||||||
|
assert.Contains(
|
||||||
|
t, stderr.String(), config.DotEnvPath,
|
||||||
|
"the refusal must name the file",
|
||||||
|
)
|
||||||
|
assert.Empty(
|
||||||
|
t, stdout.String(),
|
||||||
|
"the subcommand must not have run",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatch_LoadsDotEnvBeforeSubcommands pins the ordering the
|
||||||
|
// godotenv/autoload import used to provide for free. It ran in an
|
||||||
|
// init(), so .env was in the environment before anything read it —
|
||||||
|
// including config.DataDir, which both the DATA_DIR lock and resetpw
|
||||||
|
// call outside the fx graph. Loading any later would let a .env that
|
||||||
|
// sets DATA_DIR lock one directory while the config opened databases
|
||||||
|
// in another.
|
||||||
|
func TestDispatch_LoadsDotEnvBeforeSubcommands(t *testing.T) {
|
||||||
|
t.Setenv(dotEnvKey, "placeholder")
|
||||||
|
require.NoError(t, os.Unsetenv(dotEnvKey))
|
||||||
|
|
||||||
|
writeDotEnvInWorkingDir(t, dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := dispatch(
|
||||||
|
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
assert.Equal(
|
||||||
|
t, "from-dot-env", os.Getenv(dotEnvKey),
|
||||||
|
"the file must be applied before the subcommand runs",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatch_MissingDotEnvIsFine pins the case most deployments are
|
||||||
|
// in: no .env at all, which must stay a normal start.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
|
func TestDispatch_MissingDotEnvIsFine(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := dispatch(
|
||||||
|
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stderr.String())
|
||||||
|
}
|
||||||
+26
-1
@@ -54,6 +54,11 @@ const stopTimeout = 5 * time.Second
|
|||||||
// caller can tell "called wrong" from "declined".
|
// caller can tell "called wrong" from "declined".
|
||||||
const exitUsage = 2
|
const exitUsage = 2
|
||||||
|
|
||||||
|
// helpCommand is the subcommand that prints usage. The flag spellings
|
||||||
|
// beside it in the switch are aliases; this is the name the usage text
|
||||||
|
// documents and the one tests invoke.
|
||||||
|
const helpCommand = "help"
|
||||||
|
|
||||||
// Build-time variables set via -ldflags.
|
// Build-time variables set via -ldflags.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // Build-time variables injected by the linker.
|
//nolint:gochecknoglobals // Build-time variables injected by the linker.
|
||||||
@@ -75,11 +80,27 @@ func main() {
|
|||||||
// every existing deployment invoke; that path is unchanged, including
|
// every existing deployment invoke; that path is unchanged, including
|
||||||
// where the DATA_DIR lock is taken relative to building the fx graph
|
// where the DATA_DIR lock is taken relative to building the fx graph
|
||||||
// and how fx propagates a non-zero exit itself.
|
// and how fx propagates a non-zero exit itself.
|
||||||
|
//
|
||||||
|
// The optional .env file is read here, before any subcommand and so
|
||||||
|
// before anything reads the environment — config.DataDir, which both
|
||||||
|
// the DATA_DIR lock and resetpw call outside the fx graph, above all.
|
||||||
|
// It used to be read from an init() in internal/config, which put it
|
||||||
|
// earlier still but threw the error away: a single malformed line
|
||||||
|
// applied none of the file and said nothing about it. A file that is
|
||||||
|
// not there stays fine, since .env is optional and most deployments
|
||||||
|
// do not have one.
|
||||||
func dispatch(
|
func dispatch(
|
||||||
args []string,
|
args []string,
|
||||||
stdin io.Reader,
|
stdin io.Reader,
|
||||||
stdout, stderr io.Writer,
|
stdout, stderr io.Writer,
|
||||||
) int {
|
) int {
|
||||||
|
err := config.LoadDotEnv()
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "%s: %v\n", appname, err)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return run(stderr)
|
return run(stderr)
|
||||||
}
|
}
|
||||||
@@ -87,7 +108,7 @@ func dispatch(
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case resetpw.Name:
|
case resetpw.Name:
|
||||||
return resetpw.Run(args[1:], stdin, stdout, stderr)
|
return resetpw.Run(args[1:], stdin, stdout, stderr)
|
||||||
case "help", "-h", "-help", "--help":
|
case helpCommand, "-h", "-help", "--help":
|
||||||
usage(stdout)
|
usage(stdout)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -157,6 +178,10 @@ func newApp() *fx.App {
|
|||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
|
// The one SSRF guard both target-creation validation
|
||||||
|
// and the delivery dialer consult, so they cannot
|
||||||
|
// disagree about a destination.
|
||||||
|
delivery.NewGuard,
|
||||||
delivery.New,
|
delivery.New,
|
||||||
delivery.NewArchiveSweeper,
|
delivery.NewArchiveSweeper,
|
||||||
// Wire *delivery.Engine as delivery.Notifier so the
|
// Wire *delivery.Engine as delivery.Notifier so the
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func TestDispatch_Help(t *testing.T) {
|
|||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
code := dispatch(
|
code := dispatch(
|
||||||
[]string{"help"}, strings.NewReader(""), &stdout, &stderr,
|
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, 0, code)
|
require.Equal(t, 0, code)
|
||||||
|
|||||||
+286
-14
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,13 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
|
||||||
// Populates the environment from a ./.env file automatically for
|
|
||||||
// development configuration. Kept in one place only (here).
|
|
||||||
_ "github.com/joho/godotenv/autoload"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +32,34 @@ const (
|
|||||||
// defaultPort is the default HTTP listen port.
|
// defaultPort is the default HTTP listen port.
|
||||||
defaultPort = 8080
|
defaultPort = 8080
|
||||||
|
|
||||||
|
// defaultBindAddress is the interface the plaintext HTTP
|
||||||
|
// listener claims when BIND_ADDRESS is unset.
|
||||||
|
//
|
||||||
|
// Loopback, because the listener speaks cleartext and serves
|
||||||
|
// both the admin UI and the unauthenticated receiver: a
|
||||||
|
// wildcard default publishes them on every interface of every
|
||||||
|
// host that never configured anything, which is the failure
|
||||||
|
// this default exists to prevent. Reaching webhooker from off
|
||||||
|
// the host is then a deliberate act — a reverse proxy in front
|
||||||
|
// of it, or an explicit BIND_ADDRESS.
|
||||||
|
//
|
||||||
|
// This is the binary's default only. The Dockerfile ships
|
||||||
|
// ENV BIND_ADDRESS=0.0.0.0, so a container deployment needs
|
||||||
|
// nothing set and is unaffected by this constant. The two
|
||||||
|
// differ because they answer different questions: a container's
|
||||||
|
// network namespace is already the boundary this default is
|
||||||
|
// reaching for, so binding every address inside it exposes
|
||||||
|
// nothing, and what decides exposure there is the publish flag
|
||||||
|
// (-p 127.0.0.1:8080:8080). A loopback bind inside a container
|
||||||
|
// buys no security and makes the process unreachable through
|
||||||
|
// its own published port.
|
||||||
|
//
|
||||||
|
// The split is expressed as two explicit defaults rather than
|
||||||
|
// container auto-detection, because a heuristic that guesses
|
||||||
|
// wrong opens the cleartext port exactly where nobody is
|
||||||
|
// looking.
|
||||||
|
defaultBindAddress = "127.0.0.1"
|
||||||
|
|
||||||
// defaultRetentionSweepInterval is how often the retention
|
// defaultRetentionSweepInterval is how often the retention
|
||||||
// reaper deletes events older than each webhook's RetentionDays.
|
// reaper deletes events older than each webhook's RetentionDays.
|
||||||
defaultRetentionSweepInterval = time.Hour
|
defaultRetentionSweepInterval = time.Hour
|
||||||
@@ -56,6 +83,12 @@ const (
|
|||||||
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
|
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
|
||||||
// covers the same addresses as an IPv4 /8.
|
// covers the same addresses as an IPv4 /8.
|
||||||
mappedV4Offset = 96
|
mappedV4Offset = 96
|
||||||
|
|
||||||
|
// DotEnvPath is the optional file of KEY=value lines read into the
|
||||||
|
// environment at startup, relative to the process working
|
||||||
|
// directory. Exported so that documentation and tests name the
|
||||||
|
// same path the loader opens.
|
||||||
|
DotEnvPath = ".env"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||||
@@ -75,6 +108,19 @@ var ErrInvalidPort = errors.New("invalid port")
|
|||||||
// nor a bare IP address.
|
// nor a bare IP address.
|
||||||
var ErrInvalidCIDR = errors.New("invalid CIDR")
|
var ErrInvalidCIDR = errors.New("invalid CIDR")
|
||||||
|
|
||||||
|
// ErrInvalidBindAddress is returned when BIND_ADDRESS is set to
|
||||||
|
// something that is not an IP address literal.
|
||||||
|
var ErrInvalidBindAddress = errors.New("invalid bind address")
|
||||||
|
|
||||||
|
// ErrInvalidSentryDSN is returned when SENTRY_DSN is set to something
|
||||||
|
// the Sentry SDK cannot parse as a DSN.
|
||||||
|
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
var ErrDotEnvUnreadable = errors.New("unreadable .env file")
|
||||||
|
|
||||||
// ErrIncompleteMetricsAuth is returned when exactly one of
|
// ErrIncompleteMetricsAuth is returned when exactly one of
|
||||||
// METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither
|
// METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither
|
||||||
// fallback is acceptable: serving /metrics on the username alone
|
// fallback is acceptable: serving /metrics on the username alone
|
||||||
@@ -105,6 +151,13 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
|
|
||||||
|
// BindAddress is the IP address the plaintext HTTP listener
|
||||||
|
// binds, as an address literal. It defaults to
|
||||||
|
// defaultBindAddress and is never empty: an empty string would
|
||||||
|
// mean the wildcard to net.Listen, which is the opposite of the
|
||||||
|
// default this ships.
|
||||||
|
BindAddress string
|
||||||
|
|
||||||
// RetentionSweepInterval is how often the retention reaper runs.
|
// RetentionSweepInterval is how often the retention reaper runs.
|
||||||
// Always positive: it becomes a time.NewTicker period.
|
// Always positive: it becomes a time.NewTicker period.
|
||||||
RetentionSweepInterval time.Duration
|
RetentionSweepInterval time.Duration
|
||||||
@@ -128,6 +181,23 @@ type Config struct {
|
|||||||
// clients.
|
// clients.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||||
|
// may reach even though the SSRF guard's default blocklist
|
||||||
|
// covers them. It is empty unless ALLOWED_EGRESS_CIDRS is set,
|
||||||
|
// and empty means every private/reserved range stays refused.
|
||||||
|
//
|
||||||
|
// This only ever adds destinations to what the guard would
|
||||||
|
// 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.
|
||||||
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -157,12 +227,62 @@ func (c *Config) MetricsAuthEnabled() bool {
|
|||||||
return c.MetricsUsername != "" && c.MetricsPassword != ""
|
return c.MetricsUsername != "" && c.MetricsPassword != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SentryEnabled reports whether error reporting is shipped to Sentry.
|
||||||
|
// It is the only answer to that question in the codebase: the SDK
|
||||||
|
// initialisation, the sentryhttp middleware registration and the
|
||||||
|
// startup log's sentryEnabled field all read this one method, so the
|
||||||
|
// log cannot report reporting as on while nothing is sending.
|
||||||
|
//
|
||||||
|
// A non-empty DSN is enough because loadFromEnv already parsed it with
|
||||||
|
// the SDK's own parser and refused to build a Config around one the
|
||||||
|
// SDK would reject, and because initialising the SDK with a DSN that
|
||||||
|
// parsed and failed anyway aborts the process rather than leaving this
|
||||||
|
// true and the client absent.
|
||||||
|
func (c *Config) SentryEnabled() bool {
|
||||||
|
return c.SentryDSN != ""
|
||||||
|
}
|
||||||
|
|
||||||
// envString returns the value of the named environment variable,
|
// envString returns the value of the named environment variable,
|
||||||
// or an empty string if not set.
|
// or an empty string if not set.
|
||||||
func envString(key string) string {
|
func envString(key string) string {
|
||||||
return os.Getenv(key)
|
return os.Getenv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadDotEnv reads DotEnvPath into the environment when that file is
|
||||||
|
// present, and reports a file that is present but broken.
|
||||||
|
//
|
||||||
|
// It has to run before anything reads the environment, so that every
|
||||||
|
// reader agrees on what the environment holds — the DATA_DIR lock
|
||||||
|
// taken before the fx graph exists as much as loadFromEnv itself. A
|
||||||
|
// variable already set in the real environment wins: godotenv never
|
||||||
|
// overwrites one.
|
||||||
|
//
|
||||||
|
// A missing file is not an error. It is a development convenience and
|
||||||
|
// most deployments set the environment directly.
|
||||||
|
//
|
||||||
|
// Any other failure is. godotenv parses the whole file before setting
|
||||||
|
// anything, so a single malformed line applies none of it: every
|
||||||
|
// variable in the file silently reverts to its default, which defeats
|
||||||
|
// the fail-loud guarantee for all of them at once.
|
||||||
|
func LoadDotEnv() error {
|
||||||
|
return loadDotEnvFile(DotEnvPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDotEnvFile is LoadDotEnv over a named file, so tests can point
|
||||||
|
// at a temporary one instead of the process working directory.
|
||||||
|
func loadDotEnvFile(path string) error {
|
||||||
|
err := godotenv.Load(path)
|
||||||
|
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s: %w; nothing in it was applied, so fix the file or "+
|
||||||
|
"remove it",
|
||||||
|
ErrDotEnvUnreadable, path, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
|
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
|
||||||
// or empty. It is exported so that entry points which must act on the
|
// or empty. It is exported so that entry points which must act on the
|
||||||
// data directory before the fx graph exists — taking the exclusive
|
// data directory before the fx graph exists — taking the exclusive
|
||||||
@@ -371,6 +491,77 @@ func envPrefixList(key string) ([]netip.Prefix, error) {
|
|||||||
return prefixes, nil
|
return prefixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envBindAddress returns the value of the named environment variable
|
||||||
|
// parsed as an IP address literal. An unset (or empty, or
|
||||||
|
// whitespace-only) value yields defaultValue.
|
||||||
|
//
|
||||||
|
// Only literals are accepted: no hostname is resolved, so `localhost`
|
||||||
|
// is an error rather than a DNS lookup at startup whose answer could
|
||||||
|
// be either loopback family, could change under the process, and
|
||||||
|
// could return several addresses of which only one would be bound. A
|
||||||
|
// value with a port in it (`127.0.0.1:8080`) is likewise an error —
|
||||||
|
// the port is PORT's business, and silently accepting it would bind
|
||||||
|
// something other than what was asked for.
|
||||||
|
//
|
||||||
|
// A set value that is not a literal is a hard error naming the key
|
||||||
|
// and the bad value, so startup fails loudly rather than falling back
|
||||||
|
// to a default the operator plainly did not want. A literal that is
|
||||||
|
// not an address of this host parses here and fails at listen time
|
||||||
|
// instead, which ends the process non-zero.
|
||||||
|
func envBindAddress(key, defaultValue string) (string, error) {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: %s: %q must be an IP address literal such as "+
|
||||||
|
"127.0.0.1, 0.0.0.0 or ::, not a hostname and not "+
|
||||||
|
"host:port: %w",
|
||||||
|
ErrInvalidBindAddress, key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envSentryDSN returns the value of the named environment variable
|
||||||
|
// checked as a Sentry DSN. An unset (or empty, or whitespace-only)
|
||||||
|
// value yields "", which means error reporting stays off — the common
|
||||||
|
// case, and a normal start.
|
||||||
|
//
|
||||||
|
// A set value is parsed with sentry.NewDsn, which is the call
|
||||||
|
// sentry.Init makes on the DSN it is handed, so what passes here is
|
||||||
|
// exactly what the SDK will accept later and the two cannot disagree.
|
||||||
|
// Reproducing the check by hand instead would cost this package its
|
||||||
|
// dependency on the SDK — already a module dependency, already linked
|
||||||
|
// into the binary — in exchange for a second definition of "valid DSN"
|
||||||
|
// free to drift from the one that decides.
|
||||||
|
//
|
||||||
|
// A set value that does not parse is a hard error naming the key, so
|
||||||
|
// startup fails loudly. Losing error reporting is the failure this
|
||||||
|
// variable exists to prevent, and a typo in a DSN is silent forever:
|
||||||
|
// nothing later in the process can notice that reports are going
|
||||||
|
// nowhere. The bad value is quoted because it is a URL to a public
|
||||||
|
// endpoint carrying a public key, not a secret.
|
||||||
|
func envSentryDSN(key string) (string, error) {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := sentry.NewDsn(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: %s: %q: %w", ErrInvalidSentryDSN, key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveMetricsAuth reads the /metrics basic-auth credentials and
|
// resolveMetricsAuth reads the /metrics basic-auth credentials and
|
||||||
// rejects a half-set pair, naming both variables either way. The
|
// rejects a half-set pair, naming both variables either way. The
|
||||||
// error carries neither value: the password is a secret.
|
// error carries neither value: the password is a secret.
|
||||||
@@ -395,12 +586,14 @@ func resolveMetricsAuth() (string, string, error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
|
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to prod
|
||||||
// dev, and rejects unrecognised values.
|
// when it is unset so a deployment that forgets the variable is not
|
||||||
|
// silently permissive; dev must be set explicitly. It rejects
|
||||||
|
// unrecognised values.
|
||||||
func resolveEnvironment() (string, error) {
|
func resolveEnvironment() (string, error) {
|
||||||
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
||||||
if environment == "" {
|
if environment == "" {
|
||||||
environment = EnvironmentDev
|
environment = EnvironmentProd
|
||||||
}
|
}
|
||||||
|
|
||||||
if environment != EnvironmentDev &&
|
if environment != EnvironmentDev &&
|
||||||
@@ -415,6 +608,27 @@ func resolveEnvironment() (string, error) {
|
|||||||
return environment, nil
|
return environment, 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
|
||||||
|
// because a validation failure in either has to abort startup before
|
||||||
|
// anything binds.
|
||||||
|
func resolveListener() (int, string, error) {
|
||||||
|
port, err := envPort("PORT", defaultPort)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
bindAddress, err := envBindAddress(
|
||||||
|
"BIND_ADDRESS", defaultBindAddress,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return port, bindAddress, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadFromEnv builds a Config from the environment. Every value that
|
// loadFromEnv builds a Config from the environment. Every value that
|
||||||
// needs parsing fails loudly when it is set but unparseable: the
|
// needs parsing fails loudly when it is set but unparseable: the
|
||||||
// documented defaults apply only to variables that are unset (or
|
// documented defaults apply only to variables that are unset (or
|
||||||
@@ -426,7 +640,7 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := envPort("PORT", defaultPort)
|
port, bindAddress, err := resolveListener()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -472,11 +686,21 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
metricsUsername, metricsPassword, err := resolveMetricsAuth()
|
metricsUsername, metricsPassword, err := resolveMetricsAuth()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sentryDSN, err := envSentryDSN("SENTRY_DSN")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
DataDir: DataDir(),
|
DataDir: DataDir(),
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
@@ -485,14 +709,57 @@ func loadFromEnv() (*Config, error) {
|
|||||||
MetricsUsername: metricsUsername,
|
MetricsUsername: metricsUsername,
|
||||||
MetricsPassword: metricsPassword,
|
MetricsPassword: metricsPassword,
|
||||||
Port: port,
|
Port: port,
|
||||||
SentryDSN: envString("SENTRY_DSN"),
|
BindAddress: bindAddress,
|
||||||
|
SentryDSN: sentryDSN,
|
||||||
RetentionSweepInterval: retentionSweepInterval,
|
RetentionSweepInterval: retentionSweepInterval,
|
||||||
SessionIdleTimeout: sessionIdleTimeout,
|
SessionIdleTimeout: sessionIdleTimeout,
|
||||||
ReceiverRateLimit: receiverRateLimit,
|
ReceiverRateLimit: receiverRateLimit,
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
|
AllowedEgressCIDRs: allowedEgressCIDRs,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrefixStrings renders a prefix list as its CIDR strings, for
|
||||||
|
// logging a list an operator has to be able to read back.
|
||||||
|
func PrefixStrings(prefixes []netip.Prefix) []string {
|
||||||
|
out := make([]string, 0, len(prefixes))
|
||||||
|
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
out = append(out, prefix.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnEgressAllowlist logs the effective ALLOWED_EGRESS_CIDRS
|
||||||
|
// whenever it is non-empty.
|
||||||
|
//
|
||||||
|
// It prints the blocks themselves rather than a count, because
|
||||||
|
// this is the one setting that lets a delivery target reach the
|
||||||
|
// host's own network: an operator reading the startup log has to
|
||||||
|
// be able to see exactly which hole is open. Silence means the
|
||||||
|
// list is empty and the SSRF guard is refusing every
|
||||||
|
// private/reserved range, which is the default.
|
||||||
|
func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
||||||
|
if len(c.AllowedEgressCIDRs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn(
|
||||||
|
"ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+
|
||||||
|
"otherwise-blocked networks. Anyone who can create a "+
|
||||||
|
"delivery target can now make this process issue "+
|
||||||
|
"requests into them, and read back the response. Only "+
|
||||||
|
"the addresses the README lists as blocked "+
|
||||||
|
"unconditionally stay blocked regardless of what is "+
|
||||||
|
"listed here; a public cloud metadata address such as "+
|
||||||
|
"168.63.129.16 is reachable once it, or a block "+
|
||||||
|
"covering it, is listed.",
|
||||||
|
"allowedEgressCIDRs",
|
||||||
|
strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// warnSharedRateLimitBucket logs a startup warning whenever
|
// warnSharedRateLimitBucket logs a startup warning whenever
|
||||||
// TRUSTED_PROXIES is empty, in any environment.
|
// TRUSTED_PROXIES is empty, in any environment.
|
||||||
//
|
//
|
||||||
@@ -510,10 +777,8 @@ func loadFromEnv() (*Config, error) {
|
|||||||
// everyone else's wrong passwords, and the receiver's limits become
|
// everyone else's wrong passwords, and the receiver's limits become
|
||||||
// service-wide ceilings.
|
// service-wide ceilings.
|
||||||
//
|
//
|
||||||
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That
|
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
||||||
// variable defaults to dev, so gating on it would silence the warning
|
// behind a proxy every client shares one bucket in dev and prod alike.
|
||||||
// for exactly the operator who forgot to configure the deployment —
|
|
||||||
// the case it exists to catch.
|
|
||||||
//
|
//
|
||||||
// The default of trusting nobody is deliberate — trusting forwarded
|
// The default of trusting nobody is deliberate — trusting forwarded
|
||||||
// headers from arbitrary peers lets any client choose its own bucket —
|
// headers from arbitrary peers lets any client choose its own bucket —
|
||||||
@@ -564,6 +829,11 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
log.Info("Configuration loaded",
|
log.Info("Configuration loaded",
|
||||||
"environment", s.Environment,
|
"environment", s.Environment,
|
||||||
"port", s.Port,
|
"port", s.Port,
|
||||||
|
// Logged because which interfaces the cleartext listener
|
||||||
|
// answers on is not otherwise observable from inside a
|
||||||
|
// container, and it decides whether anything but the local
|
||||||
|
// host can reach the admin UI.
|
||||||
|
"bindAddress", s.BindAddress,
|
||||||
"debug", s.Debug,
|
"debug", s.Debug,
|
||||||
"maintenanceMode", s.MaintenanceMode,
|
"maintenanceMode", s.MaintenanceMode,
|
||||||
"dataDir", s.DataDir,
|
"dataDir", s.DataDir,
|
||||||
@@ -574,11 +844,13 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
||||||
"receiverRateLimit", s.ReceiverRateLimit,
|
"receiverRateLimit", s.ReceiverRateLimit,
|
||||||
"trustedProxies", len(s.TrustedProxies),
|
"trustedProxies", len(s.TrustedProxies),
|
||||||
"hasSentryDSN", s.SentryDSN != "",
|
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
||||||
|
"sentryEnabled", s.SentryEnabled(),
|
||||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
s.warnSharedRateLimitBucket(log)
|
s.warnSharedRateLimitBucket(log)
|
||||||
|
s.warnEgressAllowlist(log)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-11
@@ -44,9 +44,9 @@ func TestEnvironmentConfig(t *testing.T) {
|
|||||||
isProd bool
|
isProd bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default is dev",
|
name: "default is prod",
|
||||||
isDev: true,
|
isDev: false,
|
||||||
isProd: false,
|
isProd: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "explicit dev",
|
name: "explicit dev",
|
||||||
@@ -663,14 +663,195 @@ func testTrustedProxiesSuccess(
|
|||||||
assert.Equal(t, expected, got)
|
assert.Equal(t, expected, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAllowedEgressCIDRs covers ALLOWED_EGRESS_CIDRS, the escape
|
||||||
|
// hatch that lets a self-hosted deployment forward to its own
|
||||||
|
// network. Unset it must stay empty, so the SSRF guard keeps
|
||||||
|
// refusing every private/reserved range; a set-but-unparseable
|
||||||
|
// value must abort startup naming the variable rather than
|
||||||
|
// silently running with a list the operator did not write.
|
||||||
|
func TestAllowedEgressCIDRs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expected []string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: caseUnsetUsesDefault,
|
||||||
|
set: false,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value yields empty list",
|
||||||
|
set: true,
|
||||||
|
value: "",
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: caseValidValueParsed,
|
||||||
|
set: true,
|
||||||
|
value: cidrPrivateV4,
|
||||||
|
expected: []string{cidrPrivateV4},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple blocks with whitespace",
|
||||||
|
set: true,
|
||||||
|
value: " 10.0.0.0/8 , 127.0.0.0/8 ",
|
||||||
|
expected: []string{cidrPrivateV4, "127.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare address becomes a single host",
|
||||||
|
set: true,
|
||||||
|
value: "172.17.0.5",
|
||||||
|
expected: []string{"172.17.0.5/32"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: caseUnparseableFails,
|
||||||
|
set: true,
|
||||||
|
value: cidrPrivateV4 + ",not-an-address",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "out-of-range prefix length fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "10.0.0.0/33",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "dev")
|
||||||
|
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
expectStartupErrorFor(
|
||||||
|
t, "ALLOWED_EGRESS_CIDRS", config.ErrInvalidCIDR,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
testAllowedEgressCIDRsSuccess(t, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAllowedEgressCIDRsSuccess(
|
||||||
|
t *testing.T,
|
||||||
|
expected []string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
|
app := fxtest.New(
|
||||||
|
t,
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&cfg),
|
||||||
|
)
|
||||||
|
require.NoError(t, app.Err())
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
defer app.RequireStop()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, expected, config.PrefixStrings(cfg.AllowedEgressCIDRs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEgressAllowlistWarning covers the startup log that shows an
|
||||||
|
// operator the hole ALLOWED_EGRESS_CIDRS opened. It must stay
|
||||||
|
// silent on the default (empty) list and, when set, print the
|
||||||
|
// blocks themselves rather than a count.
|
||||||
|
func TestEgressAllowlistWarning(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowed string
|
||||||
|
expectWarning bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty allowlist is quiet",
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-empty allowlist warns",
|
||||||
|
allowed: "10.0.0.0/8,127.0.0.0/8",
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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", config.EnvironmentDev)
|
||||||
|
|
||||||
|
if tt.allowed == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
log := slog.New(slog.NewJSONHandler(
|
||||||
|
&buf, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, config.WarnEgressAllowlistForTest(log),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tt.expectWarning {
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, logged, `"level":"WARN"`)
|
||||||
|
assert.Contains(t, logged, "ALLOWED_EGRESS_CIDRS")
|
||||||
|
// The blocks themselves, not a count: the operator has
|
||||||
|
// to be able to read back which networks are open.
|
||||||
|
assert.Contains(t, logged, "10.0.0.0/8")
|
||||||
|
assert.Contains(t, logged, "127.0.0.0/8")
|
||||||
|
// What stays shut is the whole unconditional set, not
|
||||||
|
// link-local alone; a public metadata address is not in
|
||||||
|
// it, so a listed block covering it opens it.
|
||||||
|
assert.Contains(t, logged, "blocked unconditionally")
|
||||||
|
assert.Contains(t, logged, "168.63.129.16 is reachable")
|
||||||
|
// The listed blocks need not be private or reserved.
|
||||||
|
assert.NotContains(t, logged, "private/reserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSharedRateLimitBucketWarning covers the startup warning that
|
// TestSharedRateLimitBucketWarning covers the startup warning that
|
||||||
// tells an operator a deployment behind a reverse proxy shares one
|
// tells an operator a deployment behind a reverse proxy shares one
|
||||||
// rate-limit bucket between every client, which turns the receiver
|
// rate-limit bucket between every client, which turns the receiver
|
||||||
// limits into service-wide ceilings and collapses login failure
|
// limits into service-wide ceilings and collapses login failure
|
||||||
// counting. It must fire whenever TRUSTED_PROXIES is empty,
|
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||||
// in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating
|
// environment, because behind a proxy every client shares one bucket
|
||||||
// on it would silence the warning for exactly the operator who never
|
// in dev and prod alike. It stays quiet once proxies are named.
|
||||||
// configured the deployment. It stays quiet once proxies are named.
|
|
||||||
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -690,10 +871,6 @@ func TestSharedRateLimitBucketWarning(t *testing.T) {
|
|||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The default environment. An internet-exposed
|
|
||||||
// deployment whose operator never set
|
|
||||||
// WEBHOOKER_ENVIRONMENT lands here and has exactly
|
|
||||||
// the exposure the warning announces.
|
|
||||||
name: "dev without trusted proxies warns",
|
name: "dev without trusted proxies warns",
|
||||||
environment: config.EnvironmentDev,
|
environment: config.EnvironmentDev,
|
||||||
expectWarning: true,
|
expectWarning: true,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dotEnvKey is a throwaway variable name the .env tests write and
|
||||||
|
// read, so they cannot disturb real configuration.
|
||||||
|
const dotEnvKey = "WEBHOOKER_TEST_DOTENV_VALUE"
|
||||||
|
|
||||||
|
// malformedDotEnv is a file godotenv cannot parse. The first line is
|
||||||
|
// the realistic typo — a space where the `=` belongs — and the rest
|
||||||
|
// make sure nothing downstream treats the file as salvageable line by
|
||||||
|
// line.
|
||||||
|
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 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_MissingFileIsFine pins the case most deployments are
|
||||||
|
// in. The file is optional: it is a development convenience, and a
|
||||||
|
// deployment that configures the environment directly must start
|
||||||
|
// normally rather than be refused for a file it was never meant to
|
||||||
|
// have.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
|
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||||
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
|
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
|
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
||||||
|
|
||||||
|
_, present := os.LookupEnv(dotEnvKey)
|
||||||
|
assert.False(t, present, "nothing may be set from an absent file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_AppliesValues pins that a well-formed file still
|
||||||
|
// reaches the environment, which is the whole reason the file is read
|
||||||
|
// at all.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
|
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||||
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
|
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
|
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
||||||
|
assert.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_RealEnvironmentWins pins that the file cannot
|
||||||
|
// override a variable the process was actually started with. A
|
||||||
|
// deployment that sets DATA_DIR in its unit file must not have it
|
||||||
|
// silently replaced by a stale .env left in the working directory.
|
||||||
|
func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
|
||||||
|
t.Setenv(dotEnvKey, "from-environment")
|
||||||
|
|
||||||
|
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
|
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
||||||
|
assert.Equal(t, "from-environment", os.Getenv(dotEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_MalformedFileAborts is the defect this fixes. One bad
|
||||||
|
// line makes godotenv apply none of the file, so every variable in it
|
||||||
|
// 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.
|
||||||
|
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||||
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
|
path := writeDotEnv(
|
||||||
|
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := config.LoadDotEnvFileForTest(path)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, config.ErrDotEnvUnreadable)
|
||||||
|
assert.Contains(
|
||||||
|
t, err.Error(), config.DotEnvPath,
|
||||||
|
"the failure must name the file it could not read",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, present := os.LookupEnv(dotEnvKey)
|
||||||
|
assert.False(
|
||||||
|
t, present,
|
||||||
|
"a rejected file must apply nothing, not part of itself",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_UnreadableFileAborts pins that only absence is
|
||||||
|
// tolerated. A .env that exists but cannot be read is a file the
|
||||||
|
// operator meant to be applied, so it fails like a malformed one
|
||||||
|
// rather than being treated as though it were not there.
|
||||||
|
func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A directory in the file's place: open succeeds and the read
|
||||||
|
// fails, which no umask or root-ness can turn back into success
|
||||||
|
// the way a chmod could.
|
||||||
|
path := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
|
require.NoError(t, os.Mkdir(path, 0o750))
|
||||||
|
|
||||||
|
err := config.LoadDotEnvFileForTest(path)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, config.ErrDotEnvUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDotEnv_ReadsTheWorkingDirectory pins the path LoadDotEnv
|
||||||
|
// itself opens, which the tests above bypass. It is relative to the
|
||||||
|
// process working directory, as it was under godotenv/autoload and as
|
||||||
|
// the README documents.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
|
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
||||||
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, config.DotEnvPath),
|
||||||
|
[]byte(dotEnvKey+"=from-working-directory\n"),
|
||||||
|
0o600,
|
||||||
|
))
|
||||||
|
t.Chdir(dir)
|
||||||
|
|
||||||
|
require.NoError(t, config.LoadDotEnv())
|
||||||
|
assert.Equal(t, "from-working-directory", os.Getenv(dotEnvKey))
|
||||||
|
}
|
||||||
+331
-52
@@ -21,6 +21,22 @@ const (
|
|||||||
envKeyPort = "PORT"
|
envKeyPort = "PORT"
|
||||||
envKeyDebug = "DEBUG"
|
envKeyDebug = "DEBUG"
|
||||||
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||||
|
envKeyBindAddress = "BIND_ADDRESS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sample BIND_ADDRESS values used by the tables below.
|
||||||
|
const (
|
||||||
|
// bindAddressDefault is the shipped default. It is asserted
|
||||||
|
// against the package's own constant in
|
||||||
|
// TestNewUsesDefaultsWhenUnset, so the two cannot drift.
|
||||||
|
bindAddressDefault = "127.0.0.1"
|
||||||
|
|
||||||
|
// bindAddressWildcard is the value a container deployment sets.
|
||||||
|
bindAddressWildcard = "0.0.0.0"
|
||||||
|
|
||||||
|
// bindAddressSample is an arbitrary specific address, standing
|
||||||
|
// for "one interface of several".
|
||||||
|
bindAddressSample = "10.1.2.3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// envBoolCase is one row of the envBool table.
|
// envBoolCase is one row of the envBool table.
|
||||||
@@ -291,6 +307,160 @@ func TestEnvPort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEnvBindAddress covers BIND_ADDRESS parsing.
|
||||||
|
//
|
||||||
|
// Only IP address literals are accepted. Every rejection below is a
|
||||||
|
// value an operator plausibly writes — a hostname, a host:port, a
|
||||||
|
// CIDR block — and each has to abort startup rather than fall back to
|
||||||
|
// the default, because falling back would bind an address other than
|
||||||
|
// the one asked for and, in the wildcard-default case this setting
|
||||||
|
// exists to end, publish cleartext on every interface.
|
||||||
|
func TestEnvBindAddress(t *testing.T) {
|
||||||
|
for _, tt := range envBindAddressCases() {
|
||||||
|
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.EnvBindAddressForTest(
|
||||||
|
testEnvKey, bindAddressDefault,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, config.ErrInvalidBindAddress)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBindAddressCase is one row of the envBindAddress table.
|
||||||
|
type envBindAddressCase struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
expected string
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBindAddressCases is the envBindAddress table, kept out of the
|
||||||
|
// test body so the test itself stays readable.
|
||||||
|
func envBindAddressCases() []envBindAddressCase {
|
||||||
|
return append(
|
||||||
|
envBindAddressAcceptedCases(),
|
||||||
|
envBindAddressRejectedCases()...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBindAddressAcceptedCases are the values that parse: the three
|
||||||
|
// spellings of "unset" that take the default, and the literals.
|
||||||
|
func envBindAddressAcceptedCases() []envBindAddressCase {
|
||||||
|
return []envBindAddressCase{
|
||||||
|
{
|
||||||
|
name: "unset returns the default",
|
||||||
|
expected: bindAddressDefault,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty returns the default",
|
||||||
|
set: true,
|
||||||
|
value: "",
|
||||||
|
expected: bindAddressDefault,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace returns the default",
|
||||||
|
set: true,
|
||||||
|
value: " ",
|
||||||
|
expected: bindAddressDefault,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv4 wildcard is parsed",
|
||||||
|
set: true,
|
||||||
|
value: bindAddressWildcard,
|
||||||
|
expected: bindAddressWildcard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv4 literal is parsed",
|
||||||
|
set: true,
|
||||||
|
value: bindAddressSample,
|
||||||
|
expected: bindAddressSample,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surrounding whitespace is trimmed",
|
||||||
|
set: true,
|
||||||
|
value: " " + bindAddressSample + " ",
|
||||||
|
expected: bindAddressSample,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 wildcard is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "::",
|
||||||
|
expected: "::",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 literal is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "2001:db8::5",
|
||||||
|
expected: "2001:db8::5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBindAddressRejectedCases are the values that abort startup.
|
||||||
|
// Each is something an operator plausibly writes, and none may fall
|
||||||
|
// back to the default: the default is loopback, so a silent fallback
|
||||||
|
// would bind somewhere other than what was asked for.
|
||||||
|
func envBindAddressRejectedCases() []envBindAddressCase {
|
||||||
|
return []envBindAddressCase{
|
||||||
|
{
|
||||||
|
name: "garbage is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "not-an-address",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "localhost",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable hostname is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "no-such-host.invalid",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host and port is rejected",
|
||||||
|
set: true,
|
||||||
|
value: bindAddressDefault + ":8080",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bracketed ipv6 is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "[::1]",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CIDR block is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "10.0.0.0/8",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// buildConfig constructs a Config through fx exactly as the
|
// buildConfig constructs a Config through fx exactly as the
|
||||||
// application does, returning the config and any construction error.
|
// application does, returning the config and any construction error.
|
||||||
func buildConfig(t *testing.T) (*config.Config, error) {
|
func buildConfig(t *testing.T) (*config.Config, error) {
|
||||||
@@ -312,58 +482,7 @@ func buildConfig(t *testing.T) (*config.Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewRejectsBadEnvValues(t *testing.T) {
|
func TestNewRejectsBadEnvValues(t *testing.T) {
|
||||||
tests := []struct {
|
for _, tt := range badEnvValueCases() {
|
||||||
name string
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
expectError bool
|
|
||||||
check func(t *testing.T, cfg *config.Config)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid PORT is used",
|
|
||||||
key: envKeyPort,
|
|
||||||
value: "9001",
|
|
||||||
check: func(t *testing.T, cfg *config.Config) {
|
|
||||||
t.Helper()
|
|
||||||
assert.Equal(t, 9001, cfg.Port)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unparseable PORT aborts startup",
|
|
||||||
key: envKeyPort,
|
|
||||||
value: "eighty-eighty",
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "out-of-range PORT aborts startup",
|
|
||||||
key: envKeyPort,
|
|
||||||
value: "70000",
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid DEBUG is used",
|
|
||||||
key: envKeyDebug,
|
|
||||||
value: "true",
|
|
||||||
check: func(t *testing.T, cfg *config.Config) {
|
|
||||||
t.Helper()
|
|
||||||
assert.True(t, cfg.Debug)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unparseable DEBUG aborts startup",
|
|
||||||
key: envKeyDebug,
|
|
||||||
value: "ture",
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unparseable MAINTENANCE_MODE aborts startup",
|
|
||||||
key: envKeyMaintenanceMode,
|
|
||||||
value: "sometimes",
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
@@ -387,6 +506,149 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// badEnvValueCase is one row of the config.New table: a variable, the
|
||||||
|
// value it is set to, and either the assertion that startup fails
|
||||||
|
// naming both, or a check on the Config that resulted.
|
||||||
|
type badEnvValueCase struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
check func(t *testing.T, cfg *config.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// badEnvValueCases is the config.New table, kept out of the test body
|
||||||
|
// so the test itself stays readable. It is assembled from per-variable
|
||||||
|
// groups because one literal covering every variable outgrew the
|
||||||
|
// function-length budget.
|
||||||
|
func badEnvValueCases() []badEnvValueCase {
|
||||||
|
cases := listenerEnvValueCases()
|
||||||
|
cases = append(cases, flagEnvValueCases()...)
|
||||||
|
cases = append(cases, sentryEnvValueCases()...)
|
||||||
|
|
||||||
|
return cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenerEnvValueCases covers the two variables that describe the
|
||||||
|
// HTTP listener.
|
||||||
|
func listenerEnvValueCases() []badEnvValueCase {
|
||||||
|
return []badEnvValueCase{
|
||||||
|
{
|
||||||
|
name: "valid PORT is used",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "9001",
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, 9001, cfg.Port)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable PORT aborts startup",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "eighty-eighty",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "out-of-range PORT aborts startup",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "70000",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid BIND_ADDRESS is used",
|
||||||
|
key: envKeyBindAddress,
|
||||||
|
value: bindAddressWildcard,
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(
|
||||||
|
t, bindAddressWildcard, cfg.BindAddress,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable BIND_ADDRESS aborts startup",
|
||||||
|
key: envKeyBindAddress,
|
||||||
|
value: "not-an-address",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname BIND_ADDRESS aborts startup",
|
||||||
|
key: envKeyBindAddress,
|
||||||
|
value: "localhost",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIND_ADDRESS with a port aborts startup",
|
||||||
|
key: envKeyBindAddress,
|
||||||
|
value: bindAddressDefault + ":8080",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagEnvValueCases covers the boolean variables.
|
||||||
|
func flagEnvValueCases() []badEnvValueCase {
|
||||||
|
return []badEnvValueCase{
|
||||||
|
{
|
||||||
|
name: "valid DEBUG is used",
|
||||||
|
key: envKeyDebug,
|
||||||
|
value: "true",
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.True(t, cfg.Debug)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable DEBUG aborts startup",
|
||||||
|
key: envKeyDebug,
|
||||||
|
value: "ture",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||||
|
key: envKeyMaintenanceMode,
|
||||||
|
value: "sometimes",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentryEnvValueCases covers SENTRY_DSN. The three rejected values are
|
||||||
|
// the ones measured on the defect: each initialised the SDK with an
|
||||||
|
// error and left the process serving with error reporting off.
|
||||||
|
func sentryEnvValueCases() []badEnvValueCase {
|
||||||
|
return []badEnvValueCase{
|
||||||
|
{
|
||||||
|
name: "valid SENTRY_DSN is used",
|
||||||
|
key: envKeySentryDSN,
|
||||||
|
value: validSentryDSN,
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, validSentryDSN, cfg.SentryDSN)
|
||||||
|
assert.True(t, cfg.SentryEnabled())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable SENTRY_DSN aborts startup",
|
||||||
|
key: envKeySentryDSN,
|
||||||
|
value: "not-a-dsn",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SENTRY_DSN that is not a URL aborts startup",
|
||||||
|
key: envKeySentryDSN,
|
||||||
|
value: "%%%",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyless SENTRY_DSN aborts startup",
|
||||||
|
key: envKeySentryDSN,
|
||||||
|
value: "https://example.invalid/1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewUsesDefaultsWhenUnset proves the fail-loud behaviour did not
|
// TestNewUsesDefaultsWhenUnset proves the fail-loud behaviour did not
|
||||||
// break the legitimate unset case: absent variables still get their
|
// break the legitimate unset case: absent variables still get their
|
||||||
// documented defaults.
|
// documented defaults.
|
||||||
@@ -395,6 +657,7 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
|||||||
|
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||||
|
envKeyBindAddress, envKeySentryDSN,
|
||||||
} {
|
} {
|
||||||
require.NoError(t, os.Unsetenv(key))
|
require.NoError(t, os.Unsetenv(key))
|
||||||
}
|
}
|
||||||
@@ -406,4 +669,20 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
|||||||
assert.Equal(t, 8080, cfg.Port)
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
assert.False(t, cfg.Debug)
|
assert.False(t, cfg.Debug)
|
||||||
assert.False(t, cfg.MaintenanceMode)
|
assert.False(t, cfg.MaintenanceMode)
|
||||||
|
|
||||||
|
// Loopback, not the wildcard: the default must not publish the
|
||||||
|
// cleartext admin UI and the unauthenticated receiver on every
|
||||||
|
// interface of a host that configured nothing. The value is read
|
||||||
|
// from the package rather than repeated, so the README's
|
||||||
|
// documented default and the compiled-in one are pinned to the
|
||||||
|
// same constant.
|
||||||
|
assert.Equal(
|
||||||
|
t, config.DefaultBindAddressForTest, cfg.BindAddress,
|
||||||
|
)
|
||||||
|
assert.Equal(t, bindAddressDefault, cfg.BindAddress)
|
||||||
|
|
||||||
|
// An absent SENTRY_DSN is the common case and must stay a normal
|
||||||
|
// start with error reporting off, not a refusal.
|
||||||
|
assert.Empty(t, cfg.SentryDSN)
|
||||||
|
assert.False(t, cfg.SentryEnabled())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
|
|||||||
return nil
|
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
|
||||||
|
// the list is non-empty and that it names the blocks it opened.
|
||||||
|
func WarnEgressAllowlistForTest(log *slog.Logger) error {
|
||||||
|
c, err := loadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.warnEgressAllowlist(log)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// EnvBoolForTest exposes envBool.
|
// EnvBoolForTest exposes envBool.
|
||||||
func EnvBoolForTest(key string, defaultValue bool) (bool, error) {
|
func EnvBoolForTest(key string, defaultValue bool) (bool, error) {
|
||||||
return envBool(key, defaultValue)
|
return envBool(key, defaultValue)
|
||||||
@@ -35,3 +50,25 @@ func EnvPositiveIntForTest(key string, defaultValue int) (int, error) {
|
|||||||
func EnvPortForTest(key string, defaultValue int) (int, error) {
|
func EnvPortForTest(key string, defaultValue int) (int, error) {
|
||||||
return envPort(key, defaultValue)
|
return envPort(key, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnvSentryDSNForTest exposes envSentryDSN.
|
||||||
|
func EnvSentryDSNForTest(key string) (string, error) {
|
||||||
|
return envSentryDSN(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDotEnvFileForTest exposes the loader LoadDotEnv runs, over a
|
||||||
|
// caller-named file rather than the process working directory, so
|
||||||
|
// each .env state can be covered without moving the test process.
|
||||||
|
func LoadDotEnvFileForTest(path string) error {
|
||||||
|
return loadDotEnvFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvBindAddressForTest exposes envBindAddress.
|
||||||
|
func EnvBindAddressForTest(key, defaultValue string) (string, error) {
|
||||||
|
return envBindAddress(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultBindAddressForTest exposes the compiled-in BIND_ADDRESS
|
||||||
|
// default, so a test pins the documented value rather than repeating
|
||||||
|
// a literal that could drift from it.
|
||||||
|
const DefaultBindAddressForTest = defaultBindAddress
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// envKeySentryDSN is the variable envSentryDSN reads in production.
|
||||||
|
const envKeySentryDSN = "SENTRY_DSN"
|
||||||
|
|
||||||
|
// validSentryDSN is a syntactically complete DSN. The host is under
|
||||||
|
// .invalid (RFC 2606), so nothing a test builds around it can reach a
|
||||||
|
// real Sentry installation.
|
||||||
|
const validSentryDSN = "https://abc123@sentry.invalid/42"
|
||||||
|
|
||||||
|
// envSentryDSNCase is one row of the envSentryDSN table.
|
||||||
|
type envSentryDSNCase struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
expected string
|
||||||
|
}
|
||||||
|
|
||||||
|
// envSentryDSNCases is the envSentryDSN table. The three invalid
|
||||||
|
// values are the ones measured on the defect: each initialised the SDK
|
||||||
|
// with an error and left the process serving with reporting off.
|
||||||
|
func envSentryDSNCases() []envSentryDSNCase {
|
||||||
|
return []envSentryDSNCase{
|
||||||
|
{
|
||||||
|
name: "unset means reporting off",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty means reporting off",
|
||||||
|
set: true,
|
||||||
|
value: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace means reporting off",
|
||||||
|
set: true,
|
||||||
|
value: " ",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a valid DSN is kept",
|
||||||
|
set: true,
|
||||||
|
value: validSentryDSN,
|
||||||
|
expected: validSentryDSN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surrounding whitespace is trimmed",
|
||||||
|
set: true,
|
||||||
|
value: " " + validSentryDSN + "\t",
|
||||||
|
expected: validSentryDSN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a value that is not a URL is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "not-a-dsn",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an unparseable URL is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "%%%",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a DSN without a public key is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "https://example.invalid/1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a DSN without a project id is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "https://abc123@sentry.invalid/",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a non-HTTP scheme is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "ftp://abc123@sentry.invalid/42",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvSentryDSN covers the helper directly. What it pins beyond the
|
||||||
|
// value is the failure shape: a set-but-unparseable DSN names the
|
||||||
|
// variable and the value, exactly as the other fail-loud helpers do,
|
||||||
|
// so an operator reads the fix off the message.
|
||||||
|
func TestEnvSentryDSN(t *testing.T) {
|
||||||
|
for _, tt := range envSentryDSNCases() {
|
||||||
|
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(envKeySentryDSN, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(envKeySentryDSN))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, config.ErrInvalidSentryDSN)
|
||||||
|
assert.Contains(t, err.Error(), envKeySentryDSN)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentryEnabled_TracksTheDSN pins that the one method answering
|
||||||
|
// "is anything being reported" agrees with the DSN in every state. The
|
||||||
|
// startup log, the SDK initialisation and the sentryhttp middleware
|
||||||
|
// all read it, so a log field cannot report reporting as on while
|
||||||
|
// nothing is sending.
|
||||||
|
func TestSentryEnabled_TracksTheDSN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(t, (&config.Config{}).SentryEnabled())
|
||||||
|
assert.True(
|
||||||
|
t,
|
||||||
|
(&config.Config{SentryDSN: validSentryDSN}).SentryEnabled(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,15 +15,14 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
|
||||||
"sneak.berlin/go/webhooker/internal/banner"
|
"sneak.berlin/go/webhooker/internal/banner"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dataDirPerm = 0750
|
|
||||||
randomPasswordLen = 16
|
randomPasswordLen = 16
|
||||||
sessionKeyLen = 32
|
sessionKeyLen = 32
|
||||||
)
|
)
|
||||||
@@ -187,7 +185,9 @@ func (d *Database) connect() error {
|
|||||||
// caller's decision.
|
// caller's decision.
|
||||||
func (d *Database) connectTo(dataDir string) error {
|
func (d *Database) connectTo(dataDir string) error {
|
||||||
// Ensure the data directory exists before opening the database.
|
// Ensure the data directory exists before opening the database.
|
||||||
err := os.MkdirAll(dataDir, dataDirPerm)
|
// datadir.DirPerm is the single source of the directory mode; this
|
||||||
|
// package creates the directory too, since either may run first.
|
||||||
|
err := os.MkdirAll(dataDir, datadir.DirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"creating data directory %s: %w",
|
"creating data directory %s: %w",
|
||||||
@@ -198,13 +198,11 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
|
|
||||||
// Construct the main application database path inside DATA_DIR.
|
// Construct the main application database path inside DATA_DIR.
|
||||||
dbPath := filepath.Join(dataDir, MainDBFileName)
|
dbPath := filepath.Join(dataDir, MainDBFileName)
|
||||||
dbURL := fmt.Sprintf(
|
|
||||||
"file:%s?cache=shared&mode=rwc",
|
|
||||||
dbPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Open the database with the pure Go SQLite driver
|
// Opened through OpenSQLite so this handle carries the same WAL
|
||||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
// journaling, busy timeout, immediate-transaction locking, and pool
|
||||||
|
// bounds as every other database file. See sqlite_open.go.
|
||||||
|
sqlDB, err := OpenSQLite(dbPath, SQLiteModeCreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to open database",
|
"failed to open database",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
indexes := []struct {
|
||||||
|
model any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&database.Delivery{}, "idx_deliveries_status"},
|
||||||
|
{&database.Delivery{}, "idx_deliveries_event_id"},
|
||||||
|
{&database.DeliveryResult{}, "idx_delivery_results_delivery_id"},
|
||||||
|
{&database.Event{}, "idx_events_deleted_at_created_at"},
|
||||||
|
{&database.Event{}, "idx_events_created_at"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventTierQueriesUseTheirIndexes verifies that the statements the
|
||||||
|
// indexes are for use them. GORM builds each statement in a dry run as
|
||||||
|
// the code named above it does, soft-delete condition included, and
|
||||||
|
// SQLite, which keeps no statistics on these tables, must plan to seek
|
||||||
|
// on each index listed by the columns in parentheses.
|
||||||
|
func TestEventTierQueriesUseTheirIndexes(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})
|
||||||
|
ids := []string{
|
||||||
|
uuid.New().String(), uuid.New().String(), uuid.New().String(),
|
||||||
|
}
|
||||||
|
cutoff := time.Now()
|
||||||
|
|
||||||
|
var (
|
||||||
|
deliveries []database.Delivery
|
||||||
|
results []database.DeliveryResult
|
||||||
|
depths []struct{ Depth int }
|
||||||
|
)
|
||||||
|
|
||||||
|
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<?)"
|
||||||
|
|
||||||
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
|
// stranded pending deliveries, and the queue depth count.
|
||||||
|
assertPlanUses(t, db, dry.Where(
|
||||||
|
"status = ?", database.DeliveryStatusRetrying,
|
||||||
|
).Find(&deliveries), byStatus)
|
||||||
|
assertPlanUses(t, db, dry.Where(
|
||||||
|
"status = ? AND updated_at < ?",
|
||||||
|
database.DeliveryStatusPending, cutoff,
|
||||||
|
).Limit(500).Find(&deliveries), byStatus)
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Select("target_id", "status", "count(*) as depth").
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).Group("target_id, status").Find(&depths), byStatus)
|
||||||
|
|
||||||
|
// The event log: each event's deliveries, then their attempts
|
||||||
|
// (loadEventsWithDeliveries, loadDeliveryResults).
|
||||||
|
assertPlanUses(t, db, dry.Where("event_id = ?", ids[0]).
|
||||||
|
Find(&deliveries), byEvent)
|
||||||
|
assertPlanUses(t, db, dry.Where("delivery_id IN ?", ids).
|
||||||
|
Order("attempt_num ASC").Find(&results),
|
||||||
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
|
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||||
|
// afresh for each statement as it builds them.
|
||||||
|
expiredEventIDs := func() *gorm.DB {
|
||||||
|
return dry.Model(&database.Event{}).Select("id").
|
||||||
|
Where("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
|
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||||
|
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||||
|
).Delete(&database.DeliveryResult{}),
|
||||||
|
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
|
"event_id IN (?)", expiredEventIDs(),
|
||||||
|
).Delete(&database.Delivery{}),
|
||||||
|
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
|
"created_at < ?", cutoff,
|
||||||
|
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
|
// names each of the given indexes.
|
||||||
|
func assertPlanUses(
|
||||||
|
t *testing.T, db, built *gorm.DB, indexes ...string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var plan []struct{ Detail string }
|
||||||
|
|
||||||
|
require.NoError(t, db.Raw(
|
||||||
|
"EXPLAIN QUERY PLAN "+built.Statement.SQL.String(),
|
||||||
|
built.Statement.Vars...,
|
||||||
|
).Scan(&plan).Error)
|
||||||
|
|
||||||
|
for _, index := range indexes {
|
||||||
|
assert.Contains(t, fmt.Sprint(plan), index,
|
||||||
|
built.Statement.SQL.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEntrypointSignatureColumnsMigrateToUnconfigured pins the
|
|
||||||
// upgrade path for a deployment that already has entrypoints.
|
|
||||||
//
|
|
||||||
// The signature columns arrive through GORM's AutoMigrate, so every
|
|
||||||
// row written before they existed acquires them with no value. That
|
|
||||||
// has to land on "not configured", because the alternative is an
|
|
||||||
// upgrade that rejects the traffic the operator was already
|
|
||||||
// receiving — a self-inflicted outage on a receiver whose senders
|
|
||||||
// cannot be told to start signing.
|
|
||||||
//
|
|
||||||
// The legacy schema is reproduced by dropping the columns from a
|
|
||||||
// migrated database and writing a row through the old shape, so the
|
|
||||||
// row really predates them rather than merely being blank.
|
|
||||||
func TestEntrypointSignatureColumnsMigrateToUnconfigured(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, lc := setupTestDB(t)
|
|
||||||
lc.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(lc.RequireStop)
|
|
||||||
|
|
||||||
for _, column := range []string{
|
|
||||||
"signature_scheme", "signature_secret",
|
|
||||||
} {
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.DB().Exec(
|
|
||||||
"ALTER TABLE entrypoints DROP COLUMN "+column,
|
|
||||||
).Error,
|
|
||||||
"dropping %s to reproduce the pre-upgrade schema",
|
|
||||||
column,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyID = "legacy-entrypoint"
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.DB().Exec(
|
|
||||||
`INSERT INTO entrypoints
|
|
||||||
(id, created_at, updated_at, webhook_id, path,
|
|
||||||
description, active)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
legacyID, "2026-01-01 00:00:00", "2026-01-01 00:00:00",
|
|
||||||
"legacy-webhook", "legacy-path", "predates signatures",
|
|
||||||
true,
|
|
||||||
).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The upgrade.
|
|
||||||
require.NoError(t, db.Migrate())
|
|
||||||
|
|
||||||
var ep database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.DB().Where("id = ?", legacyID).First(&ep).Error,
|
|
||||||
"the migrated row must still load; a NULL landing in a "+
|
|
||||||
"string column would fail here",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, database.SignatureSchemeNone, ep.SignatureScheme)
|
|
||||||
assert.Empty(t, ep.SignatureSecret)
|
|
||||||
assert.False(t, ep.SignatureConfigured())
|
|
||||||
assert.True(t, ep.Active, "the row's other columns survive")
|
|
||||||
|
|
||||||
// The behaviour that actually matters: an unsigned request to
|
|
||||||
// this entrypoint is still accepted.
|
|
||||||
assert.NoError(
|
|
||||||
t,
|
|
||||||
signature.Verify(&ep, http.Header{}, []byte(`{"a":1}`)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
|
|
||||||
@@ -29,12 +31,19 @@ func (s DeliveryStatus) Terminal() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delivery represents a delivery attempt for an event to a target
|
// Delivery represents a delivery attempt for an event to a target
|
||||||
|
//
|
||||||
|
//nolint:lll // a struct tag cannot wrap
|
||||||
type Delivery struct {
|
type Delivery struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null" json:"eventId"`
|
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" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
|
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
|
||||||
|
// of the event_id and status indexes, for the reason DeliveryResult
|
||||||
|
// gives.
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
// DeliveryResult represents the result of a delivery attempt
|
// DeliveryResult represents the result of a delivery attempt
|
||||||
|
//
|
||||||
|
//nolint:lll // a struct tag cannot wrap
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
|
// DeliveryID and DeletedAt make up one index, in that order.
|
||||||
|
// DeletedAt repeats the BaseModel field only to join it: GORM adds
|
||||||
|
// "deleted_at IS NULL" to almost every query, and where a column is
|
||||||
|
// matched against several values SQLite otherwise reads through the
|
||||||
|
// deleted_at index, which every live row matches.
|
||||||
|
DeliveryID string `gorm:"type:uuid;not null;index:idx_delivery_results_delivery_id,priority:1" json:"deliveryId"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_delivery_results_delivery_id,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
AttemptNum int `gorm:"not null" json:"attemptNum"`
|
AttemptNum int `gorm:"not null" json:"attemptNum"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
StatusCode int `json:"statusCode,omitempty"`
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
|||||||
@@ -1,71 +1,20 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
// SignatureScheme names the way an entrypoint authenticates inbound
|
|
||||||
// requests. A scheme fixes both the header the signature arrives in
|
|
||||||
// and the algorithm used to check it, so an operator cannot pair one
|
|
||||||
// sender's header with another sender's comparison.
|
|
||||||
type SignatureScheme string
|
|
||||||
|
|
||||||
// Signature scheme values. The empty scheme means the entrypoint
|
|
||||||
// performs no inbound verification: it is the default, and it is the
|
|
||||||
// state every entrypoint created before this column existed migrates
|
|
||||||
// to, so an existing deployment keeps accepting the requests it
|
|
||||||
// accepted before.
|
|
||||||
const (
|
|
||||||
SignatureSchemeNone SignatureScheme = ""
|
|
||||||
SignatureSchemeGitHub SignatureScheme = "github"
|
|
||||||
SignatureSchemeGitLab SignatureScheme = "gitlab"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entrypoint represents an inbound URL endpoint that feeds into a webhook
|
// Entrypoint represents an inbound URL endpoint that feeds into a webhook
|
||||||
type Entrypoint struct {
|
type Entrypoint struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
|
|
||||||
// Path is the URL path for this entrypoint.
|
// Path is the URL path for this entrypoint. It is the
|
||||||
|
// entrypoint's only credential: possession of the UUID
|
||||||
|
// authorises submission, so the receiver checks nothing else
|
||||||
|
// about the sender.
|
||||||
Path string `gorm:"uniqueIndex;not null" json:"path"`
|
Path string `gorm:"uniqueIndex;not null" json:"path"`
|
||||||
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// SignatureScheme selects how inbound requests to this
|
|
||||||
// entrypoint are authenticated. Empty means unauthenticated,
|
|
||||||
// which is what a UUID-only entrypoint has always been.
|
|
||||||
SignatureScheme SignatureScheme `gorm:"default:''" json:"signatureScheme"`
|
|
||||||
|
|
||||||
// SignatureSecret is the secret shared with the sender.
|
|
||||||
//
|
|
||||||
// It is stored in the clear because HMAC verification needs the
|
|
||||||
// key itself: a hash of it cannot recompute the sender's digest.
|
|
||||||
// It is therefore a live credential, and json:"-" keeps it out of
|
|
||||||
// any handler that marshals the model, the way APIKey.Key and
|
|
||||||
// Target.Config are kept out. handlers.EntrypointView is the
|
|
||||||
// matching barrier for the HTML path.
|
|
||||||
SignatureSecret string `gorm:"default:''" json:"-"`
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignatureConfigured reports whether this entrypoint verifies
|
|
||||||
// inbound requests. Both halves must be present: a scheme without a
|
|
||||||
// secret, or a secret without a scheme, is a broken configuration
|
|
||||||
// rather than a configured one, and signature.Verify fails those
|
|
||||||
// closed rather than treating them as "off".
|
|
||||||
func (e *Entrypoint) SignatureConfigured() bool {
|
|
||||||
return e.SignatureScheme != SignatureSchemeNone &&
|
|
||||||
e.SignatureSecret != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignatureHalfConfigured reports whether exactly one half of the
|
|
||||||
// scheme/secret pair is present. The receiver refuses such a row on
|
|
||||||
// every request, so the UI must not describe it as unverified. It
|
|
||||||
// reports the state without exposing the secret, which is why it
|
|
||||||
// lives here rather than in the display projection.
|
|
||||||
func (e *Entrypoint) SignatureHalfConfigured() bool {
|
|
||||||
hasScheme := e.SignatureScheme != SignatureSchemeNone
|
|
||||||
hasSecret := e.SignatureSecret != ""
|
|
||||||
|
|
||||||
return hasScheme != hasSecret
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// Event represents a captured webhook event
|
// Event represents a captured webhook event
|
||||||
|
//
|
||||||
|
//nolint:lll // a struct tag cannot wrap
|
||||||
type Event struct {
|
type Event struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
|
// CreatedAt and DeletedAt repeat the BaseModel fields only to index
|
||||||
|
// them for retention, which finds events by age. Its lookups carry
|
||||||
|
// GORM's "deleted_at IS NULL" (see DeliveryResult) and compare
|
||||||
|
// created_at with <, so their index has deleted_at first: SQLite
|
||||||
|
// 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"`
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||||
|
|
||||||
@@ -13,6 +31,14 @@ type Event struct {
|
|||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ func marshalModel(t *testing.T, v any) string {
|
|||||||
// - APIKey.Key is a bearer token outright.
|
// - APIKey.Key is a bearer token outright.
|
||||||
// - Setting.Value holds the session encryption key.
|
// - Setting.Value holds the session encryption key.
|
||||||
// - User.Password holds the Argon2 hash, and was already tagged.
|
// - User.Password holds the Argon2 hash, and was already tagged.
|
||||||
// - Entrypoint.SignatureSecret is the secret its senders sign with,
|
|
||||||
// stored in the clear because HMAC verification needs the key.
|
|
||||||
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -74,14 +72,6 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
|||||||
Password: marker,
|
Password: marker,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "entrypoint signature secret",
|
|
||||||
model: database.Entrypoint{
|
|
||||||
Description: keptField,
|
|
||||||
SignatureScheme: database.SignatureSchemeGitHub,
|
|
||||||
SignatureSecret: marker,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -115,24 +105,3 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
|||||||
assert.NotContains(t, encoded, marker)
|
assert.NotContains(t, encoded, marker)
|
||||||
assert.Contains(t, encoded, keptField)
|
assert.Contains(t, encoded, keptField)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebhookMarshalsNoEntrypointSecret covers the same nested case
|
|
||||||
// for the entrypoint's inbound signature secret, which reaches a
|
|
||||||
// marshalled webhook through the Entrypoints association.
|
|
||||||
func TestWebhookMarshalsNoEntrypointSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const marker = "QQENTRYPOINTMARKERQQ"
|
|
||||||
|
|
||||||
encoded := marshalModel(t, database.Webhook{
|
|
||||||
Name: keptField,
|
|
||||||
Entrypoints: []database.Entrypoint{{
|
|
||||||
Path: "some-uuid",
|
|
||||||
SignatureScheme: database.SignatureSchemeGitLab,
|
|
||||||
SignatureSecret: marker,
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.NotContains(t, encoded, marker)
|
|
||||||
assert.Contains(t, encoded, keptField)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,57 @@
|
|||||||
package database
|
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
|
// User represents a user of the webhooker service
|
||||||
|
//
|
||||||
|
//nolint:lll // a struct tag cannot wrap
|
||||||
type User struct {
|
type User struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
|
||||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||||
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BeforeSave rejects a username longer than MaxUsernameBytes, so every
|
||||||
|
// path that saves a user through GORM gets ErrUsernameTooLong rather
|
||||||
|
// than the database's constraint error. The check constraint behind it
|
||||||
|
// holds for 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerOnly is the mode every SQLite file the service owns must have.
|
||||||
|
// Spelled out rather than referencing database.SQLiteFilePerm so the
|
||||||
|
// test fails if the constant itself is loosened.
|
||||||
|
const ownerOnly fs.FileMode = 0o600
|
||||||
|
|
||||||
|
// requireOwnerOnly asserts that path exists and is readable and
|
||||||
|
// writable by its owner and by nobody else.
|
||||||
|
func requireOwnerOnly(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
require.NoError(t, err, "%s must exist", path)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
ownerOnly,
|
||||||
|
info.Mode().Perm(),
|
||||||
|
"%s holds credentials and must not be readable by "+
|
||||||
|
"anyone but its owner",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireDatabaseSetOwnerOnly asserts the mode of a database file and
|
||||||
|
// of both WAL sidecars. The sidecars carry the same rows as the
|
||||||
|
// database, so tightening only the main file fixes nothing.
|
||||||
|
func requireDatabaseSetOwnerOnly(t *testing.T, dbPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
requireOwnerOnly(t, dbPath)
|
||||||
|
requireOwnerOnly(t, dbPath+"-wal")
|
||||||
|
requireOwnerOnly(t, dbPath+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainDatabaseFilesAreOwnerOnly covers the tier the defect was
|
||||||
|
// reported against: webhooker.db holds targets.config in plaintext —
|
||||||
|
// bearer tokens, API keys, Slack webhook URLs — and the session
|
||||||
|
// encryption key.
|
||||||
|
func TestMainDatabaseFilesAreOwnerOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
l, err := logger.New(lc, logger.LoggerParams{
|
||||||
|
Globals: &globals.Globals{
|
||||||
|
Appname: testAppname,
|
||||||
|
Version: testVersion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A directory the application creates itself, not one t.TempDir
|
||||||
|
// made at 0700, so the mode below is the application's.
|
||||||
|
dataDir := filepath.Join(t.TempDir(), "data")
|
||||||
|
|
||||||
|
db, err := database.New(lc, database.DatabaseParams{
|
||||||
|
Config: &config.Config{DataDir: dataDir},
|
||||||
|
Logger: l,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
// Write through the real model so the WAL is populated and both
|
||||||
|
// sidecars are on disk while the handle is open.
|
||||||
|
require.NoError(t, db.DB().Create(&database.Webhook{
|
||||||
|
Name: testWebhookName,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
requireDatabaseSetOwnerOnly(
|
||||||
|
t, filepath.Join(dataDir, database.MainDBFileName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The data directory grants nothing to `other`. Asserted as a
|
||||||
|
// property rather than as an exact 0750, because MkdirAll applies
|
||||||
|
// the ambient umask: the exact mode is the developer's umask as
|
||||||
|
// much as the application's request, and pinning it would make
|
||||||
|
// `make check` pass or fail on where it is run. The group bits are
|
||||||
|
// deliberately left unasserted — deployments may rely on them.
|
||||||
|
info, err := os.Stat(dataDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Zero(
|
||||||
|
t,
|
||||||
|
info.Mode().Perm()&0o007,
|
||||||
|
"the data directory must not be world-accessible",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerWebhookEventDatabaseFilesAreOwnerOnly covers the events-*.db
|
||||||
|
// tier. These carry no credential canaries since
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/206, but they hold every
|
||||||
|
// received request body and header.
|
||||||
|
func TestPerWebhookEventDatabaseFilesAreOwnerOnly(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)
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: "{}",
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
requireDatabaseSetOwnerOnly(t, mgr.DBPath(webhookID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier.
|
||||||
|
// internal/delivery builds that path and opens it through OpenSQLite,
|
||||||
|
// the same single open path exercised here, so the mode is settled for
|
||||||
|
// all three tiers in one place.
|
||||||
|
func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(
|
||||||
|
t.TempDir(), "archive-"+uuid.New().String()+".db",
|
||||||
|
)
|
||||||
|
|
||||||
|
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, sqlDB.Close()) }()
|
||||||
|
|
||||||
|
_, err = sqlDB.ExecContext(ctx, "create table t (id integer)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
requireDatabaseSetOwnerOnly(t, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenSQLiteTightensFilesLeftWorldReadable is the upgrade case: a
|
||||||
|
// data directory an earlier build left at 0644, including a
|
||||||
|
// developer's own scratch directory, is fixed when it is opened rather
|
||||||
|
// than staying exposed until it is recreated.
|
||||||
|
func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
|
||||||
|
// A database and both sidecars as the pre-fix build left them.
|
||||||
|
for _, p := range []string{path, path + "-wal", path + "-shm"} {
|
||||||
|
require.NoError(t, os.WriteFile(p, nil, 0o644)) //nolint:gosec // the mode under test
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
requireDatabaseSetOwnerOnly(t, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// empty file materialized here would turn a missing-database error
|
||||||
|
// into a silently empty one.
|
||||||
|
func TestOpenSQLiteExistingModeDoesNotCreateTheFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "absent.db")
|
||||||
|
|
||||||
|
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeExisting)
|
||||||
|
if err == nil {
|
||||||
|
// sql.Open is lazy: force the connection that fails.
|
||||||
|
require.Error(t, sqlDB.PingContext(ctx))
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
assert.ErrorIs(t, statErr, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReopenAfterRestartKeepsFilesOwnerOnly is the restart case: a
|
||||||
|
// process that closed its files must be able to open them again at
|
||||||
|
// 0600, including through a gorm handle, and the sidecars must come
|
||||||
|
// back at 0600 too rather than at SQLite's own default.
|
||||||
|
func TestReopenAfterRestartKeepsFilesOwnerOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
|
||||||
|
first, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = first.ExecContext(ctx, "create table t (id integer)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, first.Close())
|
||||||
|
|
||||||
|
second, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, second.Close()) }()
|
||||||
|
|
||||||
|
_, err = second.ExecContext(ctx, "insert into t (id) values (1)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
requireDatabaseSetOwnerOnly(t, path)
|
||||||
|
|
||||||
|
var got int
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
second.QueryRowContext(ctx, "select id from t").Scan(&got))
|
||||||
|
assert.Equal(t, 1, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every SQLite file this service opens — the main database, the
|
||||||
|
// per-webhook event databases, and the archive databases — is opened
|
||||||
|
// through OpenSQLite, so the durability settings below are properties
|
||||||
|
// of the service rather than of one call site.
|
||||||
|
//
|
||||||
|
// modernc.org/sqlite installs no busy handler and issues no pragmas of
|
||||||
|
// its own: it executes only the pragmas named in explicit `_pragma=`
|
||||||
|
// DSN parameters, and gorm.io/driver/sqlite adds none when it is
|
||||||
|
// handed an existing *sql.DB. Every setting therefore has to be
|
||||||
|
// spelled out here or it is simply not in effect.
|
||||||
|
// SQLite URI open modes.
|
||||||
|
const (
|
||||||
|
// SQLiteModeCreate creates the database file when it is missing.
|
||||||
|
SQLiteModeCreate = "rwc"
|
||||||
|
|
||||||
|
// SQLiteModeExisting requires the file to exist already.
|
||||||
|
SQLiteModeExisting = "rw"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SQLiteBusyTimeout is how long SQLite retries a lock conflict
|
||||||
|
// before returning SQLITE_BUSY.
|
||||||
|
//
|
||||||
|
// Under WAL a reader never blocks a writer, so the only conflict
|
||||||
|
// left is writer against writer: this process's delivery workers
|
||||||
|
// against each other, or against another process holding the write
|
||||||
|
// lock. Those clear in milliseconds. Ten seconds is far above that
|
||||||
|
// and still well inside the receiver's request budget, so an
|
||||||
|
// inbound webhook waits rather than being rejected with a 500.
|
||||||
|
SQLiteBusyTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// sqliteMaxOpenConns bounds the connection pool for one database
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// The pool needs a bound at all because database/sql cannot detect
|
||||||
|
// a connection left mid-transaction: modernc.org/sqlite implements
|
||||||
|
// neither driver.Validator nor driver.SessionResetter, so a
|
||||||
|
// connection whose COMMIT failed is returned to the pool with its
|
||||||
|
// transaction still open and handed out again indefinitely. That is
|
||||||
|
// what turned four `database is locked` errors into 593
|
||||||
|
// `cannot start a transaction within a transaction` in
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256.
|
||||||
|
//
|
||||||
|
// Four is above the one writer SQLite allows at a time, so reads
|
||||||
|
// still proceed while a write is in flight, and low enough that
|
||||||
|
// contention is resolved by the busy handler rather than by piling
|
||||||
|
// up connections against a lock only one of them can hold.
|
||||||
|
sqliteMaxOpenConns = 4
|
||||||
|
|
||||||
|
// sqliteMaxIdleConns keeps the pool warm without holding every
|
||||||
|
// connection open through an idle period.
|
||||||
|
sqliteMaxIdleConns = 2
|
||||||
|
|
||||||
|
// sqliteConnMaxLifetime and sqliteConnMaxIdleTime retire pooled
|
||||||
|
// connections on a schedule. With _txlock=immediate a failed
|
||||||
|
// COMMIT should no longer be reachable, but these bound the damage
|
||||||
|
// if one happens anyway: a poisoned connection is closed and
|
||||||
|
// replaced within the lifetime instead of wedging the file until
|
||||||
|
// the process restarts.
|
||||||
|
sqliteConnMaxLifetime = 5 * time.Minute
|
||||||
|
sqliteConnMaxIdleTime = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteFilePerm is the mode every SQLite file this service owns is
|
||||||
|
// created with and held at: owner read/write, nothing for group or
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// These files hold credentials in plaintext. The main database stores
|
||||||
|
// `targets.config` — bearer tokens, API keys, Slack webhook URLs — and
|
||||||
|
// the session encryption key. SQLite left to itself creates them 0644
|
||||||
|
// (see reserveSQLiteFile), which made the 0750 data directory the only
|
||||||
|
// barrier; a bind-mounted directory supplied at 0755 removes it and
|
||||||
|
// every local user on the host can read every stored credential.
|
||||||
|
//
|
||||||
|
// This is a file-mode fix and not encryption at rest. An unattended
|
||||||
|
// process needs a key it can read without a human, so the key lands
|
||||||
|
// beside the data and an attacker who can read the database can read
|
||||||
|
// it too. See https://git.eeqj.de/sneak/webhooker/issues/212.
|
||||||
|
const SQLiteFilePerm fs.FileMode = 0o600
|
||||||
|
|
||||||
|
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
||||||
|
// touches it, and tightens any sidecar already on disk.
|
||||||
|
//
|
||||||
|
// The mode has to be settled here rather than by a chmod after opening,
|
||||||
|
// because SQLite picks it: robust_open substitutes
|
||||||
|
// SQLITE_DEFAULT_FILE_PERMISSIONS (0644) whenever it is handed mode 0,
|
||||||
|
// and findCreateFileMode yields 0 for a main database opened by URI
|
||||||
|
// with no `modeof` parameter. A chmod afterwards would leave a window
|
||||||
|
// in which the credentials are on disk world-readable.
|
||||||
|
//
|
||||||
|
// Creating the file ourselves also settles the sidecars, which is the
|
||||||
|
// half that could quietly not work. SQLite does not create those at a
|
||||||
|
// mode we choose — it derives both from the main database file:
|
||||||
|
// `-wal` through findCreateFileMode, which stats the path with the
|
||||||
|
// suffix stripped, and `-shm` in unixOpenSharedMemory from an fstat of
|
||||||
|
// the already-open database descriptor. A main file at 0600 therefore
|
||||||
|
// produces sidecars at 0600. A zero-length file is a valid empty
|
||||||
|
// database, so reserving it changes nothing else.
|
||||||
|
//
|
||||||
|
// create says whether the caller is opening in a mode that may create
|
||||||
|
// the database. When it is false a missing file is left missing, so
|
||||||
|
// 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.
|
||||||
|
func reserveSQLiteFile(path string, create bool) error {
|
||||||
|
if create {
|
||||||
|
// gosec G304: the path is the database file the caller asked
|
||||||
|
// to open, and the driver is about to open the same path
|
||||||
|
// anyway. Creating it here is what fixes its mode.
|
||||||
|
f, err := os.OpenFile( //nolint:gosec // see above
|
||||||
|
path, os.O_RDWR|os.O_CREATE, SQLiteFilePerm,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("closing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// O_CREATE leaves an existing file's mode alone, and umask can only
|
||||||
|
// have narrowed a new one. Chmod settles both cases at exactly
|
||||||
|
// SQLiteFilePerm.
|
||||||
|
for _, p := range append(
|
||||||
|
[]string{path}, sqliteSidecarPaths(path)...,
|
||||||
|
) {
|
||||||
|
err := os.Chmod(p, SQLiteFilePerm)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("securing %s: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func sqliteSidecarPaths(path string) []string {
|
||||||
|
return []string{path + "-wal", path + "-shm"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLiteDSN builds the connection string for one database file.
|
||||||
|
//
|
||||||
|
// mode is the SQLite URI open mode: "rwc" to create the file when it
|
||||||
|
// is missing, "rw" to require that it already exists.
|
||||||
|
//
|
||||||
|
// Three settings carry the fix for
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256 and none of them is
|
||||||
|
// optional:
|
||||||
|
//
|
||||||
|
// - journal_mode=WAL, so a reader — an operator running
|
||||||
|
// `sqlite3 <db> .dump` over their own data — takes a snapshot
|
||||||
|
// instead of blocking every writer behind it.
|
||||||
|
//
|
||||||
|
// - busy_timeout, so a writer that does meet a lock waits for it.
|
||||||
|
// Without one SQLite gives up immediately; nothing above it
|
||||||
|
// retries.
|
||||||
|
//
|
||||||
|
// - _txlock=immediate, so every transaction takes the write lock at
|
||||||
|
// BEGIN. A deferred transaction acquires it lazily on its first
|
||||||
|
// write, and that upgrade returns SQLITE_BUSY *without* consulting
|
||||||
|
// the busy handler, because SQLite cannot block a transaction that
|
||||||
|
// may already hold a read snapshot. Such a COMMIT then fails while
|
||||||
|
// the transaction stays open on the connection. A busy timeout
|
||||||
|
// alone does not prevent this; BEGIN IMMEDIATE does, by putting
|
||||||
|
// the wait somewhere the handler applies.
|
||||||
|
//
|
||||||
|
// Note what is absent: `cache=shared`. Under a shared cache an
|
||||||
|
// in-process conflict is reported as SQLITE_LOCKED rather than
|
||||||
|
// SQLITE_BUSY, and the busy handler does not retry SQLITE_LOCKED — so
|
||||||
|
// leaving it in would have defeated the busy timeout for exactly the
|
||||||
|
// contention this service generates. Dropping it is part of the fix,
|
||||||
|
// not housekeeping.
|
||||||
|
//
|
||||||
|
// synchronous is deliberately left at SQLite's default of FULL: this
|
||||||
|
// is a webhook receiver whose one promise is that an event it answered
|
||||||
|
// 200 for is durable.
|
||||||
|
// The order of the _pragma parameters is load-bearing.
|
||||||
|
// modernc.org/sqlite executes them in the order they appear, on every
|
||||||
|
// new connection, before the connection is handed to the pool. Setting
|
||||||
|
// journal_mode first means that pragma itself runs with no busy
|
||||||
|
// handler installed: the pool opens connections lazily, so the moment
|
||||||
|
// a new one is created is a moment the database is under load, and
|
||||||
|
// PRAGMA journal_mode takes a lock. It would fail immediately with
|
||||||
|
// SQLITE_BUSY and fail the query that caused the connection to be
|
||||||
|
// opened. busy_timeout is therefore set first, so every pragma after
|
||||||
|
// it — and the whole life of the connection — is covered.
|
||||||
|
func SQLiteDSN(path, mode string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("mode", mode)
|
||||||
|
q.Set("_txlock", "immediate")
|
||||||
|
q.Add(
|
||||||
|
"_pragma",
|
||||||
|
fmt.Sprintf(
|
||||||
|
"busy_timeout(%d)",
|
||||||
|
SQLiteBusyTimeout.Milliseconds(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
q.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
|
||||||
|
return "file:" + path + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSQLite opens the SQLite file at path with the service's
|
||||||
|
// durability settings and pool bounds applied. mode is the SQLite URI
|
||||||
|
// open mode ("rwc" or "rw").
|
||||||
|
//
|
||||||
|
// The file and its WAL sidecars are settled at SQLiteFilePerm before
|
||||||
|
// the driver sees the path; see reserveSQLiteFile.
|
||||||
|
//
|
||||||
|
// The handle is returned rather than a *gorm.DB because the callers
|
||||||
|
// wrap it in gorm themselves with their own logger.
|
||||||
|
func OpenSQLite(path, mode string) (*sql.DB, error) {
|
||||||
|
err := reserveSQLiteFile(path, mode == SQLiteModeCreate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite", SQLiteDSN(path, mode))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"opening sqlite database %s: %w", path, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB.SetMaxOpenConns(sqliteMaxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(sqliteMaxIdleConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(sqliteConnMaxLifetime)
|
||||||
|
sqlDB.SetConnMaxIdleTime(sqliteConnMaxIdleTime)
|
||||||
|
|
||||||
|
return sqlDB, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// livePragma reads a pragma off a live handle. Reading the DSN back
|
||||||
|
// would prove only that the string was built; these tests assert that
|
||||||
|
// SQLite actually applied it.
|
||||||
|
func livePragma(t *testing.T, db *gorm.DB, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var v string
|
||||||
|
|
||||||
|
row := db.Raw("pragma " + name).Row()
|
||||||
|
require.NoError(t, row.Scan(&v))
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteDSNCarriesTheDurabilitySettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dsn := database.SQLiteDSN(
|
||||||
|
"/var/lib/webhooker/webhooker.db",
|
||||||
|
database.SQLiteModeCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, dsn, "journal_mode%28WAL%29")
|
||||||
|
assert.Contains(t, dsn, "busy_timeout%2810000%29")
|
||||||
|
assert.Contains(t, dsn, "_txlock=immediate")
|
||||||
|
assert.Contains(t, dsn, "mode=rwc")
|
||||||
|
|
||||||
|
// busy_timeout must come first. The driver runs these in order on
|
||||||
|
// every new connection, and PRAGMA journal_mode takes a lock — a
|
||||||
|
// connection opened while the database is busy would fail on that
|
||||||
|
// pragma, with no busy handler yet installed to wait it out.
|
||||||
|
assert.Less(
|
||||||
|
t,
|
||||||
|
strings.Index(dsn, "busy_timeout"),
|
||||||
|
strings.Index(dsn, "journal_mode"),
|
||||||
|
"busy_timeout must be applied before journal_mode",
|
||||||
|
)
|
||||||
|
|
||||||
|
// cache=shared turns an in-process conflict into SQLITE_LOCKED,
|
||||||
|
// which the busy handler does not retry. It must never come back.
|
||||||
|
// See https://git.eeqj.de/sneak/webhooker/issues/256.
|
||||||
|
assert.NotContains(t, strings.ToLower(dsn), "cache=shared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerWebhookDBAppliesPragmasOnALiveHandle is the check the issue
|
||||||
|
// asks for by name: the settings are confirmed by querying the running
|
||||||
|
// database, not by inspecting the connection string.
|
||||||
|
func TestPerWebhookDBAppliesPragmasOnALiveHandle(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)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, "wal",
|
||||||
|
strings.ToLower(livePragma(t, db, "journal_mode")),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "10000", livePragma(t, db, "busy_timeout"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
sqlDB, err := database.OpenSQLite(
|
||||||
|
filepath.Join(dir, database.MainDBFileName),
|
||||||
|
database.SQLiteModeCreate,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, sqlDB.Close()) }()
|
||||||
|
|
||||||
|
var journal string
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.
|
||||||
|
QueryRowContext(ctx, "pragma journal_mode").
|
||||||
|
Scan(&journal))
|
||||||
|
assert.Equal(t, "wal", strings.ToLower(journal))
|
||||||
|
|
||||||
|
var busy string
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.
|
||||||
|
QueryRowContext(ctx, "pragma busy_timeout").
|
||||||
|
Scan(&busy))
|
||||||
|
assert.Equal(t, "10000", busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
||||||
|
// reproduction in
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
|
||||||
|
// long-held read of their own data used to make every concurrent write
|
||||||
|
// fail. Under WAL the reader takes a snapshot and the writes proceed.
|
||||||
|
func TestConcurrentReaderDoesNotBlockWrites(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)
|
||||||
|
|
||||||
|
// A second handle on the same file, holding a read transaction
|
||||||
|
// open across every write below — what `sqlite3 <db> .dump` is.
|
||||||
|
readerSQL, err := database.OpenSQLite(
|
||||||
|
mgr.DBPath(webhookID), database.SQLiteModeExisting,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, readerSQL.Close()) }()
|
||||||
|
|
||||||
|
readerConn, err := readerSQL.Conn(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, readerConn.Close()) }()
|
||||||
|
|
||||||
|
_, err = readerConn.ExecContext(ctx, "begin deferred")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = readerConn.ExecContext(
|
||||||
|
ctx, "select count(*) from events",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for range 25 {
|
||||||
|
err = db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return tx.Create(&database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: "POST",
|
||||||
|
Body: "{}",
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = readerConn.ExecContext(ctx, "commit")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Model(&database.Event{}).Count(&count).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(t, int64(25), count)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -41,6 +41,11 @@ type WebhookDBManager struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
dbs sync.Map // map[webhookID]*gorm.DB
|
dbs sync.Map // map[webhookID]*gorm.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
|
// mu is held while a database is opened, deleted, or closed, so
|
||||||
|
// each file has at most one open handle. Reading an already cached
|
||||||
|
// handle does not take it.
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebhookDBManager creates a new WebhookDBManager and
|
// NewWebhookDBManager creates a new WebhookDBManager and
|
||||||
@@ -54,8 +59,9 @@ func NewWebhookDBManager(
|
|||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory if it doesn't exist
|
// Create data directory if it doesn't exist. datadir.DirPerm is the
|
||||||
err := os.MkdirAll(m.dataDir, dataDirPerm)
|
// single source of the directory mode; either package may run first.
|
||||||
|
err := os.MkdirAll(m.dataDir, datadir.DirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"creating data directory %s: %w",
|
"creating data directory %s: %w",
|
||||||
@@ -85,43 +91,39 @@ func (m *WebhookDBManager) GetDB(
|
|||||||
) (*gorm.DB, error) {
|
) (*gorm.DB, error) {
|
||||||
// Fast path: already open
|
// Fast path: already open
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
cachedDB, castOK := val.(*gorm.DB)
|
return asGormDB(val, webhookID)
|
||||||
if !castOK {
|
}
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w for webhook %s",
|
|
||||||
errInvalidCachedDBType,
|
|
||||||
webhookID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedDB, nil
|
// 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.
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
|
return asGormDB(val, webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: open/create the database
|
|
||||||
db, err := m.openDB(webhookID)
|
db, err := m.openDB(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store it; if another goroutine beat us, close ours
|
m.dbs.Store(webhookID, db)
|
||||||
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
|
|
||||||
if loaded {
|
|
||||||
// Another goroutine created it first; close our duplicate
|
|
||||||
sqlDB, closeErr := db.DB()
|
|
||||||
if closeErr == nil {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
existingDB, castOK := actual.(*gorm.DB)
|
return db, nil
|
||||||
if !castOK {
|
}
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w for webhook %s",
|
|
||||||
errInvalidCachedDBType,
|
|
||||||
webhookID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingDB, nil
|
// asGormDB returns a value read from the cache as the database
|
||||||
|
// handle it is.
|
||||||
|
func asGormDB(val any, webhookID string) (*gorm.DB, error) {
|
||||||
|
db, ok := val.(*gorm.DB)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w for webhook %s",
|
||||||
|
errInvalidCachedDBType,
|
||||||
|
webhookID,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
@@ -152,6 +154,11 @@ func (m *WebhookDBManager) DBExists(
|
|||||||
func (m *WebhookDBManager) DeleteDB(
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
|
// Held until the files are gone, so GetDB cannot open the file
|
||||||
|
// again between the close and the removal.
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Close and remove from cache
|
// Close and remove from cache
|
||||||
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
||||||
if gormDB, castOK := val.(*gorm.DB); castOK {
|
if gormDB, castOK := val.(*gorm.DB); castOK {
|
||||||
@@ -185,6 +192,11 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
// CloseAll closes all open per-webhook database connections.
|
// CloseAll closes all open per-webhook database connections.
|
||||||
// Called during application shutdown.
|
// Called during application shutdown.
|
||||||
func (m *WebhookDBManager) CloseAll() error {
|
func (m *WebhookDBManager) CloseAll() error {
|
||||||
|
// An open already under way finishes and is cached first, so it
|
||||||
|
// is closed here rather than cached after this loop has passed.
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
m.dbs.Range(func(key, value any) bool {
|
m.dbs.Range(func(key, value any) bool {
|
||||||
@@ -234,12 +246,11 @@ func (m *WebhookDBManager) openDB(
|
|||||||
webhookID string,
|
webhookID string,
|
||||||
) (*gorm.DB, error) {
|
) (*gorm.DB, error) {
|
||||||
path := m.dbPath(webhookID)
|
path := m.dbPath(webhookID)
|
||||||
dbURL := fmt.Sprintf(
|
|
||||||
"file:%s?cache=shared&mode=rwc",
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
// See sqlite_open.go: WAL, a busy timeout, immediate-transaction
|
||||||
|
// locking, and a bounded pool, all of which this file needs most —
|
||||||
|
// it is the one every delivery worker writes to concurrently.
|
||||||
|
sqlDB, err := OpenSQLite(path, SQLiteModeCreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"opening webhook database %s: %w",
|
"opening webhook database %s: %w",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package database_test
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -104,6 +108,54 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
|||||||
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Many callers ask for one webhook's database at the same moment,
|
||||||
|
// before it is cached. Only one of them may open the file; the others
|
||||||
|
// must wait for its handle. openDB logs one "opened per-webhook
|
||||||
|
// database" line per open, and those lines are what is counted.
|
||||||
|
func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
mgr := database.NewTestWebhookDBManagerWithLogger(
|
||||||
|
t.TempDir(),
|
||||||
|
slog.New(slog.NewTextHandler(&logs, nil)),
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Cleanup(func() { assert.NoError(t, mgr.CloseAll()) })
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
const callers = 16
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
handles := make([]*gorm.DB, callers)
|
||||||
|
errs := make([]error, callers)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range callers {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
|
||||||
|
handles[i], errs[i] = mgr.GetDB(webhookID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i := range callers {
|
||||||
|
require.NoError(t, errs[i])
|
||||||
|
assert.Same(t, handles[0], handles[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, 1,
|
||||||
|
strings.Count(logs.String(), "opened per-webhook database"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ import (
|
|||||||
// process that was killed with SIGKILL blocks nothing.
|
// process that was killed with SIGKILL blocks nothing.
|
||||||
const LockFileName = "webhooker.lock"
|
const LockFileName = "webhooker.lock"
|
||||||
|
|
||||||
// dirPerm is the mode Acquire creates DATA_DIR with. It matches what
|
// DirPerm is the mode DATA_DIR is created with. It is the single
|
||||||
// internal/database uses, since whichever runs first creates it.
|
// source of that mode: internal/database consumes it rather than
|
||||||
const dirPerm = 0o750
|
// keeping its own copy, so the two packages that both create the
|
||||||
|
// directory cannot drift into disagreeing about its permissions.
|
||||||
|
const DirPerm = 0o750
|
||||||
|
|
||||||
// ErrLocked reports that another live process holds the data
|
// ErrLocked reports that another live process holds the data
|
||||||
// directory. Callers that need to know whether a deployment is running
|
// directory. Callers that need to know whether a deployment is running
|
||||||
@@ -64,7 +66,7 @@ func Acquire(dir string) (*Lock, error) {
|
|||||||
return nil, ErrNoDir
|
return nil, ErrNoDir
|
||||||
}
|
}
|
||||||
|
|
||||||
err := os.MkdirAll(dir, dirPerm)
|
err := os.MkdirAll(dir, DirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"creating data directory %s: %w", dir, err,
|
"creating data directory %s: %w", dir, err,
|
||||||
|
|||||||
@@ -76,12 +76,20 @@ func (cb *CircuitBreaker) Allow() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CooldownRemaining returns how much time is left before
|
// CooldownRemaining returns how long a delivery that Allow refused
|
||||||
// an open circuit transitions to half-open.
|
// should wait before it is tried again. Closed, it returns zero.
|
||||||
|
// Open, it returns what is left of the cooldown, or zero once that
|
||||||
|
// has passed. Half-open, it returns the whole cooldown: the one
|
||||||
|
// probe delivery is still in flight, and if it fails the circuit
|
||||||
|
// reopens for that long.
|
||||||
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
|
|
||||||
|
if cb.state == CircuitHalfOpen {
|
||||||
|
return cb.cooldown
|
||||||
|
}
|
||||||
|
|
||||||
if cb.state != CircuitOpen {
|
if cb.state != CircuitOpen {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ func TestCircuitBreaker_CooldownRemaining_ClosedReturnsZero(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(
|
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsCooldown(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -282,9 +282,11 @@ func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(
|
|||||||
|
|
||||||
require.True(t, cb.Allow())
|
require.True(t, cb.Allow())
|
||||||
|
|
||||||
assert.Equal(t, time.Duration(0),
|
// The cooldown newShortCooldownCB gives the breaker.
|
||||||
|
assert.Equal(t, 50*time.Millisecond,
|
||||||
cb.CooldownRemaining(),
|
cb.CooldownRemaining(),
|
||||||
"half-open circuit should have zero cooldown remaining",
|
"a delivery refused while half-open should wait "+
|
||||||
|
"a whole cooldown",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ func newSSRFTestEngine() *delivery.Engine {
|
|||||||
log := slog.New(slog.DiscardHandler)
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: delivery.NewSSRFSafeTransport(),
|
Transport: delivery.NewTestGuard().
|
||||||
|
NewSSRFSafeTransport(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return delivery.NewTestEngine(log, client, 1)
|
return delivery.NewTestEngine(log, client, 1)
|
||||||
@@ -36,8 +37,8 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
|
|||||||
engine := newSSRFTestEngine()
|
engine := newSSRFTestEngine()
|
||||||
|
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"http://127.0.0.1/hook",
|
loopbackHookURL,
|
||||||
"http://169.254.169.254/latest/meta-data/",
|
metadataURL,
|
||||||
"http://[fe80::1]/hook",
|
"http://[fe80::1]/hook",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+979
-75
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -70,11 +69,12 @@ func iMainDB(t *testing.T) *gorm.DB {
|
|||||||
t.TempDir(), "main-test.db",
|
t.TempDir(), "main-test.db",
|
||||||
)
|
)
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
// Opened the way the service opens the main database, so these
|
||||||
"file:%s?cache=shared&mode=rwc", dbPath,
|
// tests cannot pass against journal and locking settings
|
||||||
|
// production does not use.
|
||||||
|
sqlDB, err := database.OpenSQLite(
|
||||||
|
dbPath, database.SQLiteModeCreate,
|
||||||
)
|
)
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dsn)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
@@ -377,6 +377,17 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
|||||||
|
|
||||||
bodyStr := event.Body
|
bodyStr := event.Body
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
|
// The target row exists because the engine confirms a scheduled
|
||||||
|
// retry's target has not been deleted before it runs it. A retry
|
||||||
|
// task whose target id names no row at all is a state the service
|
||||||
|
// does not produce: the handler read that target to build the
|
||||||
|
// task. See https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "retry-target",
|
||||||
|
database.TargetTypeHTTP, cfg, 5,
|
||||||
|
)
|
||||||
|
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"retry-target", cfg, 5, 2, &bodyStr,
|
"retry-target", cfg, 5, 2, &bodyStr,
|
||||||
@@ -456,6 +467,12 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
|||||||
)
|
)
|
||||||
|
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "retry-large",
|
||||||
|
database.TargetTypeHTTP, cfg, 5,
|
||||||
|
)
|
||||||
|
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"retry-large", cfg, 5, 2, nil,
|
"retry-large", cfg, 5, 2, nil,
|
||||||
@@ -558,6 +575,12 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
|
|||||||
|
|
||||||
bodyStr := event.Body
|
bodyStr := event.Body
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
|
||||||
|
database.TargetTypeHTTP, cfg, 5,
|
||||||
|
)
|
||||||
|
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"retry-chan-test", cfg, 5, 2, &bodyStr,
|
"retry-chan-test", cfg, 5, 2, &bodyStr,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -269,3 +271,88 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
|||||||
|
|
||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
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 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deliveryID, task := seedLogTask(t, s)
|
||||||
|
task.TargetType = database.TargetTypeDatabase
|
||||||
|
|
||||||
|
s.Engine.Notify([]delivery.Task{task})
|
||||||
|
|
||||||
|
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||||
|
|
||||||
|
return filepath.Join(
|
||||||
|
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
||||||
|
fmt.Sprintf("archive-%s.db", s.WebhookID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngine_StopHookClosesArchives is the regression test for an
|
||||||
|
// archive split across two files by a clean stop. The engine never
|
||||||
|
// closed its archive writers, so after a stop the archived rows
|
||||||
|
// could sit in archive-{id}.db-wal while archive-{id}.db held no
|
||||||
|
// table at all, and copying the .db on its own gave an empty
|
||||||
|
// database.
|
||||||
|
func TestEngine_StopHookClosesArchives(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
|
path := deliverToArchive(t, s)
|
||||||
|
require.FileExists(
|
||||||
|
t, path+"-wal",
|
||||||
|
"an open archive should have a -wal for the stop to remove",
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
|
||||||
|
|
||||||
|
wals, err := filepath.Glob(
|
||||||
|
filepath.Join(filepath.Dir(path), "archive-*.db-wal"),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(
|
||||||
|
t, wals, "a clean stop must leave no archive -wal behind",
|
||||||
|
)
|
||||||
|
|
||||||
|
// With no -wal beside it, the row can only be in the .db.
|
||||||
|
count, err := countArchivedRows(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngine_StopHookTimeoutLeavesArchivesOpen covers a stop whose
|
||||||
|
// budget runs out while a worker is still running. The archive
|
||||||
|
// writers are left open, as a kill would leave them: closing them
|
||||||
|
// would wait for any write in progress, and that worker would then
|
||||||
|
// open new writers that nothing closes.
|
||||||
|
func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
|
deliverToArchive(t, s)
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
s.Engine.EvictWebhook(s.WebhookID)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Engine.ExportWedgeWorker(release)
|
||||||
|
|
||||||
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
|
|
||||||
|
require.True(
|
||||||
|
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
|
||||||
|
"a stop that timed out must not close archive writers",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -18,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testContentType is the event content type used in tests.
|
// testContentType is the event content type used in tests.
|
||||||
@@ -37,11 +38,12 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
t.TempDir(), "events-test.db",
|
t.TempDir(), "events-test.db",
|
||||||
)
|
)
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
// Opened the way the service opens a per-webhook database, so
|
||||||
"file:%s?cache=shared&mode=rwc", dbPath,
|
// these tests cannot pass against journal and locking settings
|
||||||
|
// production does not use.
|
||||||
|
sqlDB, err := database.OpenSQLite(
|
||||||
|
dbPath, database.SQLiteModeCreate,
|
||||||
)
|
)
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dsn)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
@@ -894,6 +896,100 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordingScheduler keeps the delay of every retry it is asked to
|
||||||
|
// schedule, and schedules nothing.
|
||||||
|
type recordingScheduler struct {
|
||||||
|
delays []time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingScheduler) ScheduleRetry(
|
||||||
|
_ delivery.Task, delay time.Duration,
|
||||||
|
) {
|
||||||
|
s.delays = append(s.delays, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks proves that while a
|
||||||
|
// half-open breaker's one probe delivery is in flight, every other task
|
||||||
|
// for the target is put back with a whole cooldown as its delay rather
|
||||||
|
// than none, and that its status is written the first time the breaker
|
||||||
|
// turns it away and not on each pass after that.
|
||||||
|
func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
// Every write of retrying moves the retry counter, so on a registry
|
||||||
|
// this test owns the counter is the number of those writes.
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
e.ExportSetMetrics(metrics.New(reg))
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
cb := newShortCooldownCB(t)
|
||||||
|
e.ExportSetCircuitBreaker(targetID, cb)
|
||||||
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
|
cb.RecordFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
|
||||||
|
require.True(t, cb.Allow(), "the probe delivery should go through")
|
||||||
|
require.Equal(t, delivery.CircuitHalfOpen, cb.State())
|
||||||
|
|
||||||
|
cfg := newHTTPTargetConfig(
|
||||||
|
"http://will-not-be-called.invalid",
|
||||||
|
)
|
||||||
|
sched := &recordingScheduler{}
|
||||||
|
|
||||||
|
const queued, passes = 3, 4
|
||||||
|
|
||||||
|
for range queued {
|
||||||
|
event := seedEvent(t, db, `{"cb":"half-open"}`)
|
||||||
|
dlv := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
for range passes {
|
||||||
|
// Each pass starts from the stored row, as a retry does.
|
||||||
|
var row database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(
|
||||||
|
&row, "id = ?", dlv.ID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
fix := buildHTTPFixture(
|
||||||
|
row, event, targetID,
|
||||||
|
"test-cb-half-open", cfg, 5, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
e.ExportDeliverHTTPWithScheduler(
|
||||||
|
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDeliveryStatus(t, db, dlv.ID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, sched.delays, queued*passes)
|
||||||
|
|
||||||
|
for _, delay := range sched.delays {
|
||||||
|
// The cooldown newShortCooldownCB gives the breaker.
|
||||||
|
assert.Equal(t, 50*time.Millisecond, delay,
|
||||||
|
"a task turned away while half-open should wait "+
|
||||||
|
"a whole cooldown",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.InDelta(t, float64(queued),
|
||||||
|
mCounter(t, reg, mRetries, mTypeHTTP), 0,
|
||||||
|
"status should be written once per task, not once per pass",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1070,6 +1166,10 @@ func TestIsForwardableHeader(t *testing.T) {
|
|||||||
assert.False(t,
|
assert.False(t,
|
||||||
delivery.ExportIsForwardableHeader("Content-Length"),
|
delivery.ExportIsForwardableHeader("Content-Length"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.False(t,
|
||||||
|
delivery.ExportIsForwardableHeader("Content-Type"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTruncate(t *testing.T) {
|
func TestTruncate(t *testing.T) {
|
||||||
@@ -1151,6 +1251,81 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
|
// receiver saved as the event's ContentType, so a delivery could send
|
||||||
|
// it twice. It must go out exactly once, with a Content-Type configured
|
||||||
|
// on the target winning, then the event's ContentType.
|
||||||
|
func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
inbound string
|
||||||
|
event string
|
||||||
|
configured string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
"inbound and event agree": {
|
||||||
|
inbound: testContentType,
|
||||||
|
event: testContentType,
|
||||||
|
want: []string{testContentType},
|
||||||
|
},
|
||||||
|
"inbound and event disagree": {
|
||||||
|
inbound: "text/plain",
|
||||||
|
event: testContentType,
|
||||||
|
want: []string{testContentType},
|
||||||
|
},
|
||||||
|
"event has none": {
|
||||||
|
inbound: testContentType,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
"target configures its own": {
|
||||||
|
inbound: testContentType,
|
||||||
|
event: testContentType,
|
||||||
|
configured: "application/xml",
|
||||||
|
want: []string{"application/xml"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inbound, err := json.Marshal(map[string][]string{
|
||||||
|
headerContentType: {tc.inbound},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := &delivery.HTTPTargetConfig{}
|
||||||
|
if tc.configured != "" {
|
||||||
|
cfg.Headers = map[string]string{
|
||||||
|
headerContentType: tc.configured,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost,
|
||||||
|
"https://target.example.com/hook",
|
||||||
|
http.NoBody,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
delivery.ExportApplyRequestHeaders(
|
||||||
|
req,
|
||||||
|
&database.Event{
|
||||||
|
Headers: string(inbound),
|
||||||
|
ContentType: tc.event,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
tc.want, req.Header.Values(headerContentType),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessDelivery_RoutesToCorrectHandler(
|
func TestProcessDelivery_RoutesToCorrectHandler(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -96,7 +96,15 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assertNoTargetRows(t, dbPath)
|
assertNoTargetRows(t, dbPath)
|
||||||
|
|
||||||
// A retry.
|
// A retry. Its target exists in the main database, because the
|
||||||
|
// engine confirms a scheduled retry's target has not been
|
||||||
|
// deleted before running it; see
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "leaky-target",
|
||||||
|
database.TargetTypeHTTP, cfg, 5,
|
||||||
|
)
|
||||||
|
|
||||||
rd := iSeedDelivery(
|
rd := iSeedDelivery(
|
||||||
t, s.WebhookDB, event.ID, targetID,
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
database.DeliveryStatusRetrying,
|
database.DeliveryStatusRetrying,
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tsEventCreatedAt is the receipt time seeded on the events these
|
||||||
|
// tests deliver. It is far enough from both the zero time and from
|
||||||
|
// now that neither can be mistaken for it.
|
||||||
|
func tsEventCreatedAt() time.Time {
|
||||||
|
return time.Date(
|
||||||
|
2026, time.March, 4, 5, 6, 7, 0, time.UTC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsZeroStamp is what a Slack message renders when the event handed
|
||||||
|
// to FormatSlackMessage carries no CreatedAt.
|
||||||
|
const tsZeroStamp = "*Timestamp:* `0001-01-01T00:00:00Z`"
|
||||||
|
|
||||||
|
// tsEventBody is the body seeded on every event in this file. It is
|
||||||
|
// small enough that a Task can inline it.
|
||||||
|
const tsEventBody = `{"hello":"world"}`
|
||||||
|
|
||||||
|
// tsUndeliverableHook stands in for a Slack incoming webhook on the
|
||||||
|
// tests that never send: the config parser requires a URL, but no
|
||||||
|
// request is made.
|
||||||
|
const tsUndeliverableHook = "https://hooks.slack.com/services/T/B/x"
|
||||||
|
|
||||||
|
// tsSink is a stand-in Slack incoming webhook that records the raw
|
||||||
|
// body posted to it.
|
||||||
|
type tsSink struct {
|
||||||
|
*httptest.Server
|
||||||
|
|
||||||
|
bodies chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTSSink(t *testing.T) *tsSink {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s := &tsSink{bodies: make(chan []byte, 8)}
|
||||||
|
|
||||||
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.bodies <- body:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// text returns the Slack message text from the single payload the
|
||||||
|
// sink received.
|
||||||
|
func (s *tsSink) text(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case raw := <-s.bodies:
|
||||||
|
t.Logf("raw slack payload: %s", raw)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &payload))
|
||||||
|
|
||||||
|
return payload.Text
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("slack sink received no payload")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tsSlackConfig(t *testing.T, url string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := json.Marshal(
|
||||||
|
delivery.SlackTargetConfig{WebhookURL: url},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
|
||||||
|
// rather than the write time, so an assertion on the rendered
|
||||||
|
// timestamp cannot pass by accident against "roughly now".
|
||||||
|
func tsSeedEvent(
|
||||||
|
t *testing.T, db *gorm.DB, webhookID string,
|
||||||
|
) database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: `{}`,
|
||||||
|
Body: tsEventBody,
|
||||||
|
ContentType: "application/json",
|
||||||
|
}
|
||||||
|
event.ID = uuid.New().String()
|
||||||
|
event.CreatedAt = tsEventCreatedAt()
|
||||||
|
event.UpdatedAt = tsEventCreatedAt()
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&event).Error)
|
||||||
|
|
||||||
|
var stored database.Event
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
db.First(&stored, "id = ?", event.ID).Error,
|
||||||
|
)
|
||||||
|
require.Equal(t,
|
||||||
|
tsEventCreatedAt().UTC(), stored.CreatedAt.UTC(),
|
||||||
|
"seeded created_at did not round-trip",
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsSeedTarget writes the slack target row into the main database.
|
||||||
|
// The retry path confirms the target still exists before sending.
|
||||||
|
func tsSeedTarget(
|
||||||
|
t *testing.T, mainDB *gorm.DB, webhookID, config string,
|
||||||
|
) database.Target {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
target := database.Target{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Name: "slack-sink",
|
||||||
|
Type: database.TargetTypeSlack,
|
||||||
|
Config: config,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, mainDB.Create(&target).Error)
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func tsTask(
|
||||||
|
d database.Delivery,
|
||||||
|
event database.Event,
|
||||||
|
webhookID string,
|
||||||
|
target database.Target,
|
||||||
|
attemptNum int,
|
||||||
|
body *string,
|
||||||
|
) delivery.Task {
|
||||||
|
return delivery.Task{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
EventID: event.ID,
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: event.EntrypointID,
|
||||||
|
TargetID: target.ID,
|
||||||
|
TargetName: target.Name,
|
||||||
|
TargetType: database.TargetTypeSlack,
|
||||||
|
TargetConfig: target.Config,
|
||||||
|
MaxRetries: 0,
|
||||||
|
Method: event.Method,
|
||||||
|
Headers: event.Headers,
|
||||||
|
ContentType: event.ContentType,
|
||||||
|
Body: body,
|
||||||
|
AttemptNum: attemptNum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tsAssertRealTimestamp(t *testing.T, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.NotContains(t, text, tsZeroStamp,
|
||||||
|
"slack message carries the zero timestamp",
|
||||||
|
)
|
||||||
|
assert.Contains(t, text,
|
||||||
|
"*Timestamp:* `"+
|
||||||
|
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
|
||||||
|
"slack message does not carry the event's receipt time",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsCase is one end-to-end delivery of a seeded event to a slack
|
||||||
|
// sink, over whichever engine path `process` names.
|
||||||
|
type tsCase struct {
|
||||||
|
// status is the delivery row's status before the engine runs.
|
||||||
|
// The retry path refuses a delivery that is not retrying.
|
||||||
|
status database.DeliveryStatus
|
||||||
|
|
||||||
|
// inlineBody mirrors a Task built for a body under
|
||||||
|
// MaxInlineBodySize. When false the engine reads the body back
|
||||||
|
// from the stored row.
|
||||||
|
inlineBody bool
|
||||||
|
|
||||||
|
attemptNum int
|
||||||
|
|
||||||
|
process func(
|
||||||
|
ctx context.Context, e *delivery.Engine, task *delivery.Task,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run delivers one event through the named path and returns the
|
||||||
|
// Slack message text the sink received.
|
||||||
|
func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
sink := newTSSink(t)
|
||||||
|
|
||||||
|
cfg := tsSlackConfig(t, sink.URL)
|
||||||
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||||
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, target.ID, c.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
var body *string
|
||||||
|
|
||||||
|
if c.inlineBody {
|
||||||
|
bodyStr := event.Body
|
||||||
|
body = &bodyStr
|
||||||
|
}
|
||||||
|
|
||||||
|
task := tsTask(
|
||||||
|
d, event, s.WebhookID, target, c.attemptNum, body,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.process(context.TODO(), s.Engine, &task)
|
||||||
|
|
||||||
|
return s, d, sink.text(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlackFirstAttemptCarriesEventTimestamp covers the path an
|
||||||
|
// event takes on its first delivery: the task comes from the
|
||||||
|
// receiver and the engine reconstructs the event from it.
|
||||||
|
func TestSlackFirstAttemptCarriesEventTimestamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, d, text := tsCase{
|
||||||
|
status: database.DeliveryStatusPending,
|
||||||
|
inlineBody: true,
|
||||||
|
attemptNum: 1,
|
||||||
|
process: func(
|
||||||
|
ctx context.Context,
|
||||||
|
e *delivery.Engine,
|
||||||
|
task *delivery.Task,
|
||||||
|
) {
|
||||||
|
e.ExportProcessNewTask(ctx, task)
|
||||||
|
},
|
||||||
|
}.run(t)
|
||||||
|
|
||||||
|
tsAssertRealTimestamp(t, text)
|
||||||
|
|
||||||
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlackFirstAttemptLargeBodyCarriesEventTimestamp covers the
|
||||||
|
// first-attempt path for an event whose body exceeded
|
||||||
|
// MaxInlineBodySize, so the task carries no body and the engine
|
||||||
|
// reads it back from the stored row.
|
||||||
|
func TestSlackFirstAttemptLargeBodyCarriesEventTimestamp(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, text := tsCase{
|
||||||
|
status: database.DeliveryStatusPending,
|
||||||
|
inlineBody: false,
|
||||||
|
attemptNum: 1,
|
||||||
|
process: func(
|
||||||
|
ctx context.Context,
|
||||||
|
e *delivery.Engine,
|
||||||
|
task *delivery.Task,
|
||||||
|
) {
|
||||||
|
e.ExportProcessNewTask(ctx, task)
|
||||||
|
},
|
||||||
|
}.run(t)
|
||||||
|
|
||||||
|
tsAssertRealTimestamp(t, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlackRetryCarriesEventTimestamp covers the retry path, which
|
||||||
|
// reconstructs the event from the same task the first attempt used.
|
||||||
|
func TestSlackRetryCarriesEventTimestamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, d, text := tsCase{
|
||||||
|
status: database.DeliveryStatusRetrying,
|
||||||
|
inlineBody: true,
|
||||||
|
attemptNum: 2,
|
||||||
|
process: func(
|
||||||
|
ctx context.Context,
|
||||||
|
e *delivery.Engine,
|
||||||
|
task *delivery.Task,
|
||||||
|
) {
|
||||||
|
e.ExportProcessRetryTask(ctx, task)
|
||||||
|
},
|
||||||
|
}.run(t)
|
||||||
|
|
||||||
|
tsAssertRealTimestamp(t, text)
|
||||||
|
|
||||||
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatSlackMessageOverTaskReconstructedEvent asserts on the
|
||||||
|
// formatted message directly, over the event the delivery paths
|
||||||
|
// reconstruct from a Task. It is the unit-level guard under the
|
||||||
|
// end-to-end tests: revert the CreatedAt population in hydrateEvent
|
||||||
|
// and this fails on the zero timestamp.
|
||||||
|
func TestFormatSlackMessageOverTaskReconstructedEvent(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
||||||
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||||
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, target.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
bodyStr := event.Body
|
||||||
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
||||||
|
|
||||||
|
rebuilt, err := s.Engine.ExportEventForTask(
|
||||||
|
s.WebhookDB, &task,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, rebuilt.CreatedAt.IsZero(),
|
||||||
|
"reconstructed event carries the zero time",
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
tsEventCreatedAt().UTC(), rebuilt.CreatedAt.UTC(),
|
||||||
|
)
|
||||||
|
|
||||||
|
tsAssertRealTimestamp(
|
||||||
|
t, delivery.FormatSlackMessage(&rebuilt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatSlackMessageZeroTimestamp asserts the rendering choice
|
||||||
|
// directly, without going through the engine: a zero CreatedAt (the
|
||||||
|
// shape a reaped-row fallback produces) renders as "unknown" rather
|
||||||
|
// than the year-1 zero time, while a real CreatedAt still renders as
|
||||||
|
// RFC3339.
|
||||||
|
func TestFormatSlackMessageZeroTimestamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
zeroEvent := database.Event{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
ContentType: testContentType,
|
||||||
|
Body: tsEventBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroText := delivery.FormatSlackMessage(&zeroEvent)
|
||||||
|
|
||||||
|
assert.NotContains(t, zeroText, "0001-01-01",
|
||||||
|
"slack message carries the zero-time year",
|
||||||
|
)
|
||||||
|
assert.Contains(t, zeroText, "*Timestamp:* `unknown`",
|
||||||
|
"slack message does not mark an unset receipt time as unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
nonZeroEvent := zeroEvent
|
||||||
|
nonZeroEvent.CreatedAt = tsEventCreatedAt()
|
||||||
|
|
||||||
|
nonZeroText := delivery.FormatSlackMessage(&nonZeroEvent)
|
||||||
|
|
||||||
|
assert.Contains(t, nonZeroText,
|
||||||
|
"*Timestamp:* `"+
|
||||||
|
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
|
||||||
|
"slack message does not render a real receipt time as RFC3339",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventReconstructionSurvivesAReapedRow pins the fallback: an
|
||||||
|
// event row reaped by retention while its delivery still holds the
|
||||||
|
// body inline is still delivered, with the receipt time unset,
|
||||||
|
// rather than dropped.
|
||||||
|
func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
||||||
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||||
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, target.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
bodyStr := event.Body
|
||||||
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.Unscoped().Delete(
|
||||||
|
&database.Event{}, "id = ?", event.ID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
rebuilt, err := s.Engine.ExportEventForTask(
|
||||||
|
s.WebhookDB, &task,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, bodyStr, rebuilt.Body)
|
||||||
|
assert.True(t, rebuilt.CreatedAt.IsZero())
|
||||||
|
|
||||||
|
// A task with no inlined body has nothing left to deliver, so
|
||||||
|
// the same reaped row is an error there.
|
||||||
|
noBody := task
|
||||||
|
noBody.Body = nil
|
||||||
|
|
||||||
|
_, err = s.Engine.ExportEventForTask(s.WebhookDB, &noBody)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ const (
|
|||||||
// response is written against this number, so a test has to
|
// response is written against this number, so a test has to
|
||||||
// be able to name it.
|
// be able to name it.
|
||||||
ExportMaxBodyLog = maxBodyLog
|
ExportMaxBodyLog = maxBodyLog
|
||||||
|
|
||||||
|
// ExportPendingSweepMinAge is how long a delivery must sit at
|
||||||
|
// pending before the sweep treats it as stranded. A test has to
|
||||||
|
// name it to age a row past the bound.
|
||||||
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
||||||
@@ -39,6 +45,26 @@ func ExportIsBlockedIP(ip net.IP) bool {
|
|||||||
return isBlockedIP(ip)
|
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
|
||||||
|
// range.
|
||||||
|
func NewTestGuard(allowed ...netip.Prefix) *Guard {
|
||||||
|
return &Guard{allowed: allowed}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCheckIP exposes the guard's single decision point, so a
|
||||||
|
// test can assert the policy both the validator and the dialer
|
||||||
|
// inherit without needing a live destination.
|
||||||
|
func (g *Guard) ExportCheckIP(ip net.IP) error {
|
||||||
|
return g.checkIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportAlwaysBlockedNetworks exposes alwaysBlockedNetworks.
|
||||||
|
func ExportAlwaysBlockedNetworks() []*net.IPNet {
|
||||||
|
return alwaysBlockedNetworks
|
||||||
|
}
|
||||||
|
|
||||||
// ExportBlockedNetworks exposes blockedNetworks.
|
// ExportBlockedNetworks exposes blockedNetworks.
|
||||||
func ExportBlockedNetworks() []*net.IPNet {
|
func ExportBlockedNetworks() []*net.IPNet {
|
||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
@@ -75,6 +101,19 @@ func (e *Engine) ExportDeliverHTTP(
|
|||||||
e.httpTarget.Deliver(ctx, webhookDB, d, task, e)
|
e.httpTarget.Deliver(ctx, webhookDB, d, task, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportDeliverHTTPWithScheduler delivers via the http target, handing
|
||||||
|
// any retry to sched instead of the engine, so a test can see the
|
||||||
|
// delay each retry is given.
|
||||||
|
func (e *Engine) ExportDeliverHTTPWithScheduler(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
) {
|
||||||
|
e.httpTarget.Deliver(ctx, webhookDB, d, task, sched)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportDeliverDatabase delivers via the database target.
|
// ExportDeliverDatabase delivers via the database target.
|
||||||
func (e *Engine) ExportDeliverDatabase(
|
func (e *Engine) ExportDeliverDatabase(
|
||||||
webhookDB *gorm.DB, d *database.Delivery,
|
webhookDB *gorm.DB, d *database.Delivery,
|
||||||
@@ -125,6 +164,16 @@ func (e *Engine) ExportProcessRetryTask(
|
|||||||
e.processRetryTask(ctx, task)
|
e.processRetryTask(ctx, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportEventForTask exposes the event reconstruction the delivery
|
||||||
|
// paths run: buildEventFromTask followed by hydrateEvent.
|
||||||
|
func (e *Engine) ExportEventForTask(
|
||||||
|
webhookDB *gorm.DB, task *Task,
|
||||||
|
) (database.Event, error) {
|
||||||
|
return e.hydrateEvent(
|
||||||
|
webhookDB, buildEventFromTask(task), task,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportProcessDelivery exposes processDelivery.
|
// ExportProcessDelivery exposes processDelivery.
|
||||||
func (e *Engine) ExportProcessDelivery(
|
func (e *Engine) ExportProcessDelivery(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -143,6 +192,14 @@ func (e *Engine) ExportGetCircuitBreaker(
|
|||||||
return e.httpTarget.getCircuitBreaker(targetID)
|
return e.httpTarget.getCircuitBreaker(targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportSetCircuitBreaker makes cb the http target's circuit breaker
|
||||||
|
// for targetID, so a test can use one with a short cooldown.
|
||||||
|
func (e *Engine) ExportSetCircuitBreaker(
|
||||||
|
targetID string, cb *CircuitBreaker,
|
||||||
|
) {
|
||||||
|
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||||
func (e *Engine) ExportParseHTTPConfig(
|
func (e *Engine) ExportParseHTTPConfig(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
@@ -265,6 +322,51 @@ func (e *Engine) ExportWedgeWorker(release <-chan struct{}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportInflightHeld reports how many deliveries the engine currently
|
||||||
|
// owns, so a test can prove ownership is released rather than leaked.
|
||||||
|
func (e *Engine) ExportInflightHeld() int {
|
||||||
|
return e.inflight.held()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportRetainDelivery takes the first reference on a delivery, as the
|
||||||
|
// queueing side does. It lets a test put a delivery into the state a
|
||||||
|
// worker or a full channel would, without running the pool.
|
||||||
|
func (e *Engine) ExportRetainDelivery(deliveryID string) bool {
|
||||||
|
return e.inflight.retainIdle(deliveryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries.
|
||||||
|
func (e *Engine) ExportRecoverRetryingDeliveries(
|
||||||
|
webhookDB *gorm.DB, webhookID string,
|
||||||
|
) {
|
||||||
|
e.recoverRetryingDeliveries(webhookDB, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFailMissingTarget exposes failMissingTarget, so a test can hand
|
||||||
|
// it a delivery as a batch read it earlier.
|
||||||
|
func (e *Engine) ExportFailMissingTarget(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
d *database.Delivery,
|
||||||
|
) {
|
||||||
|
e.failMissingTarget(webhookDB, webhookID, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSendRecoveredDeliveries exposes sendRecoveredDeliveries, so a
|
||||||
|
// test can hand it a target map that lacks a delivery's target.
|
||||||
|
func (e *Engine) ExportSendRecoveredDeliveries(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
webhookID string,
|
||||||
|
targetMap map[string]database.Target,
|
||||||
|
settled map[string]struct{},
|
||||||
|
) {
|
||||||
|
e.sendRecoveredDeliveries(
|
||||||
|
ctx, webhookDB, deliveries, webhookID, targetMap, settled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportDeliveryCh returns the delivery channel.
|
// ExportDeliveryCh returns the delivery channel.
|
||||||
func (e *Engine) ExportDeliveryCh() chan Task {
|
func (e *Engine) ExportDeliveryCh() chan Task {
|
||||||
return e.deliveryCh
|
return e.deliveryCh
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// inflightSet records which deliveries the engine currently owns.
|
||||||
|
//
|
||||||
|
// A delivery is owned from the moment a task for it is handed to a
|
||||||
|
// channel or to a retry timer until the engine has no further plan for
|
||||||
|
// it in memory. Restart recovery and both arms of the periodic sweep
|
||||||
|
// re-dispatch only deliveries the set does not hold, which is what
|
||||||
|
// makes them exact rather than a guess about how long a row has sat at
|
||||||
|
// pending.
|
||||||
|
//
|
||||||
|
// This replaces reasoning from timestamps. A delivery's row says
|
||||||
|
// pending from creation until its outcome is written, which covers
|
||||||
|
// four different situations — never dispatched, waiting in a channel,
|
||||||
|
// being attempted right now, and genuinely stranded — and no column
|
||||||
|
// distinguishes them. Only the engine knows which, and it knows
|
||||||
|
// exactly. `deliveryChannelSize` is 10000 against 10 workers, so a
|
||||||
|
// perfectly healthy delivery can wait far longer than any age bound
|
||||||
|
// worth setting before its attempt even begins; an age bound alone
|
||||||
|
// re-sends it. See
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256.
|
||||||
|
//
|
||||||
|
// In-memory state is sufficient because a data directory admits one
|
||||||
|
// process: internal/datadir takes an flock on it at startup and a
|
||||||
|
// second instance refuses to run. Deliveries owned by a process that
|
||||||
|
// died are not in any successor's set, and restart recovery is what
|
||||||
|
// picks those up.
|
||||||
|
//
|
||||||
|
// References are counted rather than held as a plain set because
|
||||||
|
// ownership outlives the worker that took it. A target that schedules
|
||||||
|
// a retry from inside Deliver adds a reference while the worker still
|
||||||
|
// holds one, so the delivery stays owned across the gap between the
|
||||||
|
// worker returning and the timer firing — the window in which a sweep
|
||||||
|
// would otherwise find the row at retrying and send it again.
|
||||||
|
//
|
||||||
|
// The zero value is ready to use, and the Engine holds one by value.
|
||||||
|
// That is deliberate: an engine built by a constructor that forgot to
|
||||||
|
// initialise this would not refuse to re-dispatch anything, and the
|
||||||
|
// symptom would be duplicate deliveries rather than a failure anybody
|
||||||
|
// notices.
|
||||||
|
type inflightSet struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ids map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// retain adds a reference to a delivery the caller already knows the
|
||||||
|
// engine owns, so that ownership survives the current holder letting
|
||||||
|
// go. It cannot fail.
|
||||||
|
func (s *inflightSet) retain(deliveryID string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.ids == nil {
|
||||||
|
s.ids = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ids[deliveryID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainIdle takes the first reference on a delivery, and reports
|
||||||
|
// whether it got it. It fails when the engine already owns the
|
||||||
|
// delivery, which is what makes two claimants — restart recovery and
|
||||||
|
// the sweep run concurrently, or two sweep arms — mutually exclusive
|
||||||
|
// rather than merely atomic.
|
||||||
|
func (s *inflightSet) retainIdle(deliveryID string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.ids[deliveryID] > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ids == nil {
|
||||||
|
s.ids = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ids[deliveryID] = 1
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// release drops one reference. The delivery becomes eligible for
|
||||||
|
// re-dispatch again once the last one goes.
|
||||||
|
func (s *inflightSet) release(deliveryID string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
n := s.ids[deliveryID] - 1
|
||||||
|
if n <= 0 {
|
||||||
|
delete(s.ids, deliveryID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ids[deliveryID] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// held reports how many deliveries the engine currently owns. It
|
||||||
|
// exists so a test can assert that ownership is released rather than
|
||||||
|
// leaked: a reference that is never dropped hides its delivery from
|
||||||
|
// every sweep for the life of the process, which is the one way this
|
||||||
|
// mechanism can fail silently.
|
||||||
|
func (s *inflightSet) held() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
return len(s.ids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests pin the rule that decides whether a delivery may be
|
||||||
|
// handed back to a worker: the engine re-dispatches only what it does
|
||||||
|
// not already own. Age alone is not that rule — a healthy delivery
|
||||||
|
// waiting in a 10000-deep channel is old and must not be re-sent. See
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256.
|
||||||
|
|
||||||
|
// fSweepSetup seeds the main database with the webhook row the sweep
|
||||||
|
// enumerates, and returns the setup.
|
||||||
|
func fSweepSetup(
|
||||||
|
t *testing.T, targetID, name string,
|
||||||
|
) iSetup {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
|
s.WebhookID, name,
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.Create(&database.Webhook{
|
||||||
|
BaseModel: database.BaseModel{ID: s.WebhookID},
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Name: name,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// fDrain collects every task the engine has queued.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
func fDrain(e *delivery.Engine) []delivery.Task {
|
||||||
|
var out []delivery.Task
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case task := <-e.ExportDeliveryCh():
|
||||||
|
out = append(out, task)
|
||||||
|
case task := <-e.ExportRetryCh():
|
||||||
|
out = append(out, task)
|
||||||
|
case <-time.After(25 * time.Millisecond):
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveHandleIsWAL closes the last gap in the durability
|
||||||
|
// evidence: the main and per-webhook tiers each assert their journal
|
||||||
|
// mode on a live handle, and the archive tier gets its settings from
|
||||||
|
// the same code path but nothing checked the running file.
|
||||||
|
func TestArchiveHandleIsWAL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := delivery.NewExportArchiveWriter(
|
||||||
|
filepath.Join(t.TempDir(), "archive-wal.db"),
|
||||||
|
archiveTestLogger(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, w.Open(0))
|
||||||
|
|
||||||
|
var mode string
|
||||||
|
|
||||||
|
row := w.DB().Raw("pragma journal_mode").Row()
|
||||||
|
require.NoError(t, row.Scan(&mode))
|
||||||
|
assert.Equal(t, "wal", strings.ToLower(mode))
|
||||||
|
|
||||||
|
var busy string
|
||||||
|
|
||||||
|
row = w.DB().Raw("pragma busy_timeout").Row()
|
||||||
|
require.NoError(t, row.Scan(&busy))
|
||||||
|
assert.Equal(t, "10000", busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot
|
||||||
|
// see. The delivery is queued and untouched, so its row is arbitrarily
|
||||||
|
// old and still perfectly healthy; only ownership distinguishes it
|
||||||
|
// from a stranded one.
|
||||||
|
func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "queued")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"queued":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
// Queued exactly as the receiver queues it, and never dequeued:
|
||||||
|
// no workers are running in this engine.
|
||||||
|
s.Engine.Notify([]delivery.Task{{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
EventID: event.ID,
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
TargetID: targetID,
|
||||||
|
}})
|
||||||
|
|
||||||
|
require.Equal(t, 1, s.Engine.ExportInflightHeld())
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := fDrain(s.Engine)
|
||||||
|
assert.Len(
|
||||||
|
t, tasks, 1,
|
||||||
|
"the sweep must not queue a delivery that is "+
|
||||||
|
"already waiting for a worker",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoveryAndSweepDoNotDoubleDispatch drives the two entry points
|
||||||
|
// the engine starts concurrently against one aged pending row. Before
|
||||||
|
// ownership they both dispatched it.
|
||||||
|
func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "racing")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"racing":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for range 40 {
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Go(func() {
|
||||||
|
s.Engine.ExportRecoverPendingDeliveries(
|
||||||
|
ctx, s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
wg.Go(func() {
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
ctx, s.WebhookID,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
tasks := fDrain(s.Engine)
|
||||||
|
require.Len(
|
||||||
|
t, tasks, 1,
|
||||||
|
"delivery %s dispatched %d times",
|
||||||
|
d.ID, len(tasks),
|
||||||
|
)
|
||||||
|
|
||||||
|
// No worker runs in this engine, so the reference the winner
|
||||||
|
// took is never released and earlier iterations' deliveries
|
||||||
|
// stay owned — which is itself the property under test, since
|
||||||
|
// both paths see them on every subsequent pass.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentClaimsOfOneDeliveryYieldOneOwner exercises the
|
||||||
|
// exclusion directly, rather than arguing it from a SQL predicate.
|
||||||
|
func TestConcurrentClaimsOfOneDeliveryYieldOneOwner(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
eng := newISetup(t).Engine
|
||||||
|
deliveryID := uuid.New().String()
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
won int
|
||||||
|
)
|
||||||
|
|
||||||
|
for range 64 {
|
||||||
|
wg.Go(func() {
|
||||||
|
if eng.ExportRetainDelivery(deliveryID) {
|
||||||
|
mu.Lock()
|
||||||
|
won++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, 1, won)
|
||||||
|
assert.Equal(t, 1, eng.ExportInflightHeld())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOwnershipIsReleasedAfterDelivery guards the other direction: a
|
||||||
|
// leaked reference hides a delivery from every sweep for the life of
|
||||||
|
// the process.
|
||||||
|
func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
|
s.WebhookID, "released",
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"released":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportStart()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
require.NoError(
|
||||||
|
t, s.Engine.ExportStop(context.Background()),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
body := `{"released":true}`
|
||||||
|
|
||||||
|
s.Engine.Notify([]delivery.Task{{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
EventID: event.ID,
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
TargetID: targetID,
|
||||||
|
TargetName: "released",
|
||||||
|
TargetType: database.TargetTypeLog,
|
||||||
|
Body: &body,
|
||||||
|
EntrypointID: event.EntrypointID,
|
||||||
|
}})
|
||||||
|
|
||||||
|
iWaitForDelivered(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
assert.Eventually(
|
||||||
|
t,
|
||||||
|
func() bool {
|
||||||
|
return s.Engine.ExportInflightHeld() == 0
|
||||||
|
},
|
||||||
|
2*time.Second, 20*time.Millisecond,
|
||||||
|
"the delivery stayed owned after it was delivered",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/299. The receiver has
|
||||||
|
// written a delivery, restart recovery finds it pending, sends it and
|
||||||
|
// releases it, and only then does the receiver's Notify for it arrive.
|
||||||
|
// Nothing owns the delivery by then, so Notify takes it.
|
||||||
|
func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "recovered")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"recovered":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportStart()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
require.NoError(
|
||||||
|
t, s.Engine.ExportStop(context.Background()),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Restart recovery sends the delivery and lets it go.
|
||||||
|
iWaitForDelivered(t, s.WebhookDB, d.ID)
|
||||||
|
require.Eventually(
|
||||||
|
t,
|
||||||
|
func() bool {
|
||||||
|
return s.Engine.ExportInflightHeld() == 0
|
||||||
|
},
|
||||||
|
5*time.Second, 20*time.Millisecond,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := event.Body
|
||||||
|
|
||||||
|
s.Engine.Notify([]delivery.Task{{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
EventID: event.ID,
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
TargetID: targetID,
|
||||||
|
TargetName: "recovered",
|
||||||
|
TargetType: database.TargetTypeLog,
|
||||||
|
Body: &body,
|
||||||
|
EntrypointID: event.EntrypointID,
|
||||||
|
}})
|
||||||
|
|
||||||
|
// Notify took the delivery, and a worker releases it once it has
|
||||||
|
// run the task.
|
||||||
|
require.Eventually(
|
||||||
|
t,
|
||||||
|
func() bool {
|
||||||
|
return s.Engine.ExportInflightHeld() == 0
|
||||||
|
},
|
||||||
|
5*time.Second, 20*time.Millisecond,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Len(
|
||||||
|
t, iResults(t, s.WebhookDB, d.ID), 1,
|
||||||
|
"the delivery was sent a second time",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetryingRecoverySkipsASuccessfulResult is the retrying-side twin
|
||||||
|
// of the pending reconcile. A second attempt that reached the receiver
|
||||||
|
// and whose status write then failed sits at retrying holding a
|
||||||
|
// successful result, and re-sending it is the same duplicate.
|
||||||
|
func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "retry-settled")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"retry":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverRetryingDeliveries(
|
||||||
|
s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(
|
||||||
|
t, fDrain(s.Engine),
|
||||||
|
"a retrying delivery holding a successful result "+
|
||||||
|
"must not be sent again",
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetryingSweepSkipsASuccessfulResult is the same rule on the
|
||||||
|
// periodic sweep's retrying arm.
|
||||||
|
func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "retry-swept")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"swept":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, fDrain(s.Engine))
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
var attempts int64
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Model(&database.DeliveryResult{}).
|
||||||
|
Where("delivery_id = ?", d.ID).
|
||||||
|
Count(&attempts).Error)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(2), attempts,
|
||||||
|
"settling must not invent an attempt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduledRetryIsNotSweptDuringBackoff closes the window between
|
||||||
|
// a target scheduling a retry and the timer firing. The row says
|
||||||
|
// retrying and nothing is running, which is exactly what an orphaned
|
||||||
|
// retry looks like from the database.
|
||||||
|
func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "backoff")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"backoff":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportScheduleRetry(delivery.Task{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
EventID: event.ID,
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
TargetID: targetID,
|
||||||
|
AttemptNum: 2,
|
||||||
|
}, time.Hour)
|
||||||
|
|
||||||
|
require.Equal(t, 1, s.Engine.ExportInflightHeld())
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(
|
||||||
|
t, fDrain(s.Engine),
|
||||||
|
"the sweep must not duplicate a retry that is "+
|
||||||
|
"already scheduled",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestRedispatchStampsTheRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "stamped")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"stamped":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||||
|
require.Len(t, fDrain(s.Engine), 1)
|
||||||
|
|
||||||
|
var row database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
First(&row, "id = ?", d.ID).Error)
|
||||||
|
assert.WithinDuration(
|
||||||
|
t, time.Now(), row.UpdatedAt, time.Minute,
|
||||||
|
"a re-dispatched delivery must be stamped so the "+
|
||||||
|
"next tick does not select it again",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -229,6 +229,13 @@ func mExhaustRetries(t *testing.T, s iSetup) {
|
|||||||
body := event.Body
|
body := event.Body
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
|
// The retry below is only run if its target still exists; see
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "metrics-fail",
|
||||||
|
database.TargetTypeHTTP, cfg, 2,
|
||||||
|
)
|
||||||
|
|
||||||
first := iTask(
|
first := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-fail", cfg, 2, 1, &body,
|
"metrics-fail", cfg, 2, 1, &body,
|
||||||
@@ -289,6 +296,13 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
|
|||||||
// rather than the budget is what stops the delivery.
|
// rather than the budget is what stops the delivery.
|
||||||
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||||
|
|
||||||
|
// The retries below are only run if their target still exists;
|
||||||
|
// see https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
|
||||||
|
database.TargetTypeHTTP, cfg, maxRetries,
|
||||||
|
)
|
||||||
|
|
||||||
first := iTask(
|
first := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-trip", cfg, maxRetries, 1, &body,
|
"metrics-trip", cfg, maxRetries, 1, &body,
|
||||||
@@ -353,6 +367,11 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
|||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
|
||||||
|
database.TargetTypeHTTP, cfg, maxRetries,
|
||||||
|
)
|
||||||
|
|
||||||
first := iTask(
|
first := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-blocked", cfg, maxRetries, 1, &body,
|
"metrics-blocked", cfg, maxRetries, 1, &body,
|
||||||
@@ -393,9 +412,10 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
|||||||
|
|
||||||
s.Engine.ExportProcessRetryTask(context.TODO(), &blocked)
|
s.Engine.ExportProcessRetryTask(context.TODO(), &blocked)
|
||||||
|
|
||||||
// The breaker refused it: rescheduled, so the retry counter
|
// The breaker refused it: rescheduled without rewriting the
|
||||||
// moved, but nothing was attempted or timed.
|
// retrying status it already had, so the retry counter did not
|
||||||
assert.InDelta(t, retriesBefore+1,
|
// move, and nothing was attempted or timed.
|
||||||
|
assert.InDelta(t, retriesBefore,
|
||||||
mCounter(t, reg, mRetries, mTypeHTTP), 0)
|
mCounter(t, reg, mRetries, mTypeHTTP), 0)
|
||||||
assert.InDelta(t, threshold,
|
assert.InDelta(t, threshold,
|
||||||
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
|
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -54,12 +52,10 @@ func (q *qdSyncBuf) String() string {
|
|||||||
func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB {
|
func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
sqlDB, err := database.OpenSQLite(
|
||||||
"file:%s?cache=shared&mode=rwc",
|
|
||||||
filepath.Join(t.TempDir(), "main-gormlog.db"),
|
filepath.Join(t.TempDir(), "main-gormlog.db"),
|
||||||
|
database.SQLiteModeCreate,
|
||||||
)
|
)
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dsn)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"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/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the delivery half of
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/256: a delivery that
|
||||||
|
// reached its receiver but whose bookkeeping write failed used to be
|
||||||
|
// left at pending and re-sent on the next restart, giving the receiver
|
||||||
|
// a second copy while the event log recorded one attempt.
|
||||||
|
|
||||||
|
// rSeedResult records a DeliveryResult against a delivery, standing in
|
||||||
|
// for the attempt row the send path writes before the status.
|
||||||
|
func rSeedResult(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
success bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&database.DeliveryResult{
|
||||||
|
DeliveryID: deliveryID,
|
||||||
|
AttemptNum: attemptNum,
|
||||||
|
Success: success,
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rAgePending backdates a delivery past the sweep's age bound, which is
|
||||||
|
// what separates a stranded delivery from one a worker still holds.
|
||||||
|
func rAgePending(
|
||||||
|
t *testing.T, db *gorm.DB, deliveryID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
old := time.Now().Add(
|
||||||
|
-2 * delivery.ExportPendingSweepMinAge,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", deliveryID).
|
||||||
|
UpdateColumn("updated_at", old).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverySkipsPendingWithSuccessfulResult(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
|
s.WebhookID, "already-delivered",
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"delivered":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The delivery whose send succeeded and whose result row landed:
|
||||||
|
// only the status write failed, so it sits at pending.
|
||||||
|
done := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rSeedResult(t, s.WebhookDB, done.ID, 1, true)
|
||||||
|
|
||||||
|
// A delivery that was genuinely never attempted.
|
||||||
|
fresh := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverPendingDeliveries(
|
||||||
|
context.Background(), s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
assert.Equal(
|
||||||
|
t, fresh.ID, task.DeliveryID,
|
||||||
|
"only the unattempted delivery may be re-sent",
|
||||||
|
)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the unattempted delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
t.Fatalf(
|
||||||
|
"re-sent an already delivered delivery: %s",
|
||||||
|
task.DeliveryID,
|
||||||
|
)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is settled rather than merely skipped: leaving it pending
|
||||||
|
// would strand it again on the next sweep.
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, done.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoveryContinuesTheAttemptNumbering pins the audit trail: a
|
||||||
|
// recovered delivery that already recorded two attempts is re-sent as
|
||||||
|
// attempt three, not as attempt one again.
|
||||||
|
func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
|
s.WebhookID, "numbering",
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"numbering":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 2, false)
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverPendingDeliveries(
|
||||||
|
context.Background(), s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
assert.Equal(t, d.ID, task.DeliveryID)
|
||||||
|
assert.Equal(t, 3, task.AttemptNum)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the delivery to be recovered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepRecoversStrandedPending is the half that removes the
|
||||||
|
// restart requirement: a delivery left at pending is picked up by the
|
||||||
|
// periodic sweep.
|
||||||
|
func TestSweepRecoversStrandedPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "stranded")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"stranded":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
stranded := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, stranded.ID)
|
||||||
|
|
||||||
|
// A delivery a worker may still be holding: young, and therefore
|
||||||
|
// none of the sweep's business.
|
||||||
|
inFlight := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
assert.Equal(t, stranded.ID, task.DeliveryID)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the stranded delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
t.Fatalf(
|
||||||
|
"swept an in-flight delivery: %s",
|
||||||
|
task.DeliveryID,
|
||||||
|
)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, inFlight.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepClaimsAStrandedDeliveryOnlyOnce guards the repeat the sweep
|
||||||
|
// would otherwise be: the row stays pending for as long as the attempt
|
||||||
|
// runs, and a sweep a minute later must not send it a second time.
|
||||||
|
func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "claimed")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"claimed":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
assert.Equal(t, d.ID, task.DeliveryID)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the stranded delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delivery is still pending — nothing has run it yet — but
|
||||||
|
// the claim must keep the next sweep off it.
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
t.Fatalf(
|
||||||
|
"sent a claimed delivery again: %s",
|
||||||
|
task.DeliveryID,
|
||||||
|
)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepSettlesStrandedPendingWithoutResending is the sweep's own
|
||||||
|
// version of the reconcile: a stranded delivery holding a successful
|
||||||
|
// result is settled where it stands, and the receiver hears nothing.
|
||||||
|
func TestSweepSettlesStrandedPendingWithoutResending(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, targetID, "settled")
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"settled":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rSeedResult(t, s.WebhookDB, d.ID, 1, true)
|
||||||
|
rAgePending(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case task := <-s.Engine.ExportDeliveryCh():
|
||||||
|
t.Fatalf(
|
||||||
|
"re-sent a delivery that already succeeded: %s",
|
||||||
|
task.DeliveryID,
|
||||||
|
)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
var attempts int64
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Model(&database.DeliveryResult{}).
|
||||||
|
Where("delivery_id = ?", d.ID).
|
||||||
|
Count(&attempts).Error)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1), attempts,
|
||||||
|
"settling must not invent an attempt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedResultWriteLeavesDeliveryRecoverable is the rule the
|
||||||
|
// targets now follow: a bookkeeping write that fails must not advance
|
||||||
|
// the status, because pending and retrying are the states the sweeps
|
||||||
|
// recover and delivered is a claim the database refused to record.
|
||||||
|
func TestFailedResultWriteLeavesDeliveryRecoverable(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
var hits atomic.Int64
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drop the table the attempt row goes in, so the send succeeds
|
||||||
|
// and only the bookkeeping write fails.
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
s.WebhookDB.Exec("drop table delivery_results").Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
full.ID = d.ID
|
||||||
|
|
||||||
|
s.Engine.ExportDeliverHTTP(
|
||||||
|
context.Background(), s.WebhookDB, full,
|
||||||
|
&delivery.Task{DeliveryID: d.ID, AttemptNum: 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1), hits.Load(),
|
||||||
|
"the send itself must still happen",
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -170,7 +170,8 @@ func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders(
|
|||||||
// Stripping must not fire within the configured origin, or every
|
// Stripping must not fire within the configured origin, or every
|
||||||
// destination that redirects its own path would lose its
|
// destination that redirects its own path would lose its
|
||||||
// credential and start answering 401 — and would lose the inbound
|
// credential and start answering 401 — and would lose the inbound
|
||||||
// signature the receiver verifies.
|
// signature header the target endpoint verifies. webhooker's own
|
||||||
|
// receiver verifies no signature; it only forwards the header.
|
||||||
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
|
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -338,14 +339,18 @@ func TestRedirectPolicy_StopsAtHopCap(t *testing.T) {
|
|||||||
// The set the redirect policy strips is whatever the delivery path
|
// The set the redirect policy strips is whatever the delivery path
|
||||||
// actually put on the wire, so a header added to the forward set is
|
// actually put on the wire, so a header added to the forward set is
|
||||||
// covered without a second edit. A header the event never carried
|
// covered without a second edit. A header the event never carried
|
||||||
// is not in the set, and Content-Type is deliberately excluded: it
|
// is not in the set, and neither is the inbound Content-Type, because
|
||||||
// describes the body, which a 307 carries across hosts.
|
// it is not forwarded. Two more are deliberately excluded: a
|
||||||
|
// Content-Type configured on the target describes the body, which a
|
||||||
|
// 307 carries across hosts, and the inbound User-Agent every real
|
||||||
|
// sender supplies is overwritten before the request goes out.
|
||||||
func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
inbound, err := json.Marshal(map[string][]string{
|
inbound, err := json.Marshal(map[string][]string{
|
||||||
inboundHeaderName: {inboundHeaderValue},
|
inboundHeaderName: {inboundHeaderValue},
|
||||||
"Content-Type": {testContentType},
|
"Content-Type": {testContentType},
|
||||||
|
"User-Agent": {"curl/8.7.1"},
|
||||||
"Host": {"inbound.example.com"},
|
"Host": {"inbound.example.com"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -367,6 +372,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
&delivery.HTTPTargetConfig{
|
&delivery.HTTPTargetConfig{
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
probeHeaderName: probeHeaderValue,
|
probeHeaderName: probeHeaderValue,
|
||||||
|
"Content-Type": testContentType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -374,7 +380,11 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{probeHeaderName, inboundHeaderName}, names,
|
[]string{probeHeaderName, inboundHeaderName}, names,
|
||||||
"both header classes are reported, and only those: "+
|
"both header classes are reported, and only those: "+
|
||||||
"Host is never forwarded, Content-Type and "+
|
"Host and the inbound Content-Type are never "+
|
||||||
"User-Agent are the delivery path's own",
|
"forwarded, User-Agent is the delivery path's own",
|
||||||
|
)
|
||||||
|
assert.NotContains(t, names, "Content-Type",
|
||||||
|
"a Content-Type configured on the target must survive "+
|
||||||
|
"a cross-origin 307/308 with the body it describes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-44
@@ -6,8 +6,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,22 +26,86 @@ var (
|
|||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
errBlockedIP = errors.New(
|
errBlockedIP = errors.New(
|
||||||
"blocked private/reserved IP range",
|
"blocked private, reserved or cloud metadata address",
|
||||||
|
)
|
||||||
|
errBlockedMetadata = errors.New(
|
||||||
|
"blocked link-local or cloud instance metadata " +
|
||||||
|
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||||
)
|
)
|
||||||
errInvalidScheme = errors.New(
|
errInvalidScheme = errors.New(
|
||||||
"only http and https are allowed",
|
"only http and https are allowed",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks contains all private/reserved IP ranges
|
// blockedNetworks is the default blocklist: the private and
|
||||||
// that should be blocked to prevent SSRF attacks.
|
// 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.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
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.
|
||||||
|
//
|
||||||
|
// Inclusion criterion — an address belongs here only if BOTH
|
||||||
|
// hold, and every 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.
|
||||||
|
// That is what makes a host route free: it cannot collide
|
||||||
|
// with anything the operator runs.
|
||||||
|
// 2. Reaching it discloses credentials, or user data or
|
||||||
|
// bootstrap material — something granting onward access, or
|
||||||
|
// 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)
|
||||||
|
// 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 —
|
||||||
|
// fd00:42::42 serves /user_data and /conf rather than tokens,
|
||||||
|
// and user data routinely carries bootstrap secrets. An address
|
||||||
|
// stays out if it fails (1) however well it clears (2): a host
|
||||||
|
// route inside a block operators really assign from, such as
|
||||||
|
// 10.0.0.0/8, can collide with a real internal service and
|
||||||
|
// forfeits the justification in (1).
|
||||||
|
//
|
||||||
|
// A publicly routable unicast address does not belong here even
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// This is a criterion, not an enumeration of every metadata
|
||||||
|
// address in existence.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 169.254.0.0/16. Every entry outside the link-local blocks is a
|
||||||
|
// /32 or /128 host route, so blocking it costs an operator
|
||||||
|
// nothing else on the surrounding network.
|
||||||
|
//
|
||||||
|
// Derive membership from the address, never from the vendor's
|
||||||
|
// prose. Several providers call these endpoints "link-local" or
|
||||||
|
// even "localhost" in their own documentation while the address
|
||||||
|
// is a ULA outside fe80::/10, so a set derived from the docs
|
||||||
|
// comes out wrong.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
|
var alwaysBlockedNetworks []*net.IPNet
|
||||||
|
|
||||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
func init() {
|
func init() {
|
||||||
cidrs := []string{
|
blockedNetworks = mustParseCIDRs([]string{
|
||||||
"127.0.0.0/8",
|
"127.0.0.0/8",
|
||||||
"10.0.0.0/8",
|
"10.0.0.0/8",
|
||||||
"172.16.0.0/12",
|
"172.16.0.0/12",
|
||||||
@@ -56,7 +123,74 @@ func init() {
|
|||||||
"::1/128",
|
"::1/128",
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
}
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
|
"168.63.129.16/32",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Every entry is named. The set must not grow or shrink
|
||||||
|
// without a matching change to
|
||||||
|
// TestAlwaysBlockedNetworks_PinnedSet.
|
||||||
|
//
|
||||||
|
// The IPv4-mapped form ::ffff:169.254.169.254 needs no
|
||||||
|
// entry: net.IPNet.Contains normalises it via To4() before
|
||||||
|
// comparing, so 169.254.0.0/16 already matches it. To4()
|
||||||
|
// does not normalise the IPv4-compatible or NAT64 forms,
|
||||||
|
// which is why those are listed separately.
|
||||||
|
alwaysBlockedNetworks = mustParseCIDRs([]string{
|
||||||
|
// IPv4 link-local, carrying the 169.254.169.254
|
||||||
|
// metadata service used by AWS, Azure, DigitalOcean,
|
||||||
|
// Hetzner, OpenStack and others. Not Alibaba, which uses
|
||||||
|
// 100.100.100.200 below exclusively.
|
||||||
|
"169.254.0.0/16",
|
||||||
|
// IPv6 link-local, its IPv6 counterpart.
|
||||||
|
"fe80::/10",
|
||||||
|
|
||||||
|
// IPv6 metadata endpoints in ULA space. Each is a host
|
||||||
|
// route, and fd00::/8 is an ordinary block for an
|
||||||
|
// operator to allowlist, so without these entries that
|
||||||
|
// one allowlist line hands out cloud credentials on
|
||||||
|
// every provider below.
|
||||||
|
//
|
||||||
|
// AWS IPv6 IMDS.
|
||||||
|
"fd00:ec2::254/128",
|
||||||
|
// AWS EKS Pod Identity Agent, which issues pod identity
|
||||||
|
// credentials. A second AWS endpoint, distinct from
|
||||||
|
// IMDS above. AWS's own docs call it "localhost".
|
||||||
|
"fd00:ec2::23/128",
|
||||||
|
// GCP metadata server for IPv6-only instances.
|
||||||
|
"fd20:ce::254/128",
|
||||||
|
// Oracle OCI IMDS, serving /opc/v2 instance principals.
|
||||||
|
"fd00:c1::a9fe:a9fe/128",
|
||||||
|
// Scaleway metadata, serving /user_data and /conf.
|
||||||
|
"fd00:42::42/128",
|
||||||
|
// Linode/Akamai metadata. Akamai's docs call it
|
||||||
|
// "link-local"; it is not.
|
||||||
|
"fd00:a9fe:a9fe::1/128",
|
||||||
|
|
||||||
|
// IPv4 metadata endpoints outside link-local.
|
||||||
|
//
|
||||||
|
// Alibaba Cloud metadata. It sits in CGNAT
|
||||||
|
// 100.64.0.0/10, which Tailscale also uses, so an
|
||||||
|
// operator allowlisting a Tailscale peer's range would
|
||||||
|
// otherwise reopen it.
|
||||||
|
"100.100.100.200/32",
|
||||||
|
// Oracle Cloud Classic metadata. Inside the blocked
|
||||||
|
// 192.0.0.0/24, so this entry is what stops an
|
||||||
|
// allowlist from opening it.
|
||||||
|
"192.0.0.192/32",
|
||||||
|
|
||||||
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
|
"::a9fe:a9fe/128",
|
||||||
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
|
"64:ff9b::a9fe:a9fe/128",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseCIDRs parses a list of CIDR literals, panicking on a
|
||||||
|
// bad one. The inputs are compile-time constants, so a failure
|
||||||
|
// is a programming error rather than a runtime condition.
|
||||||
|
func mustParseCIDRs(cidrs []string) []*net.IPNet {
|
||||||
|
networks := make([]*net.IPNet, 0, len(cidrs))
|
||||||
|
|
||||||
for _, cidr := range cidrs {
|
for _, cidr := range cidrs {
|
||||||
_, network, err := net.ParseCIDR(cidr)
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
@@ -67,16 +201,15 @@ func init() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
blockedNetworks = append(
|
networks = append(networks, network)
|
||||||
blockedNetworks, network,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return networks
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP checks whether an IP address falls within
|
// matchesAny reports whether ip falls inside any of networks.
|
||||||
// any blocked private/reserved network range.
|
func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
||||||
func isBlockedIP(ip net.IP) bool {
|
for _, network := range networks {
|
||||||
for _, network := range blockedNetworks {
|
|
||||||
if network.Contains(ip) {
|
if network.Contains(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -85,9 +218,40 @@ func isBlockedIP(ip net.IP) bool {
|
|||||||
return false
|
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
|
||||||
|
// applies it in exactly one place, checkIP, which both the
|
||||||
|
// target-creation validator (ValidateTargetURL) and the delivery
|
||||||
|
// dialer call. Routing both through the same function is the
|
||||||
|
// point: when the two paths decided separately they drifted and
|
||||||
|
// disagreed, which is what made a target creatable but
|
||||||
|
// undeliverable.
|
||||||
|
//
|
||||||
|
// The guard is always on. The allowlist only ever adds specific
|
||||||
|
// networks to what the default blocklist refuses, and no
|
||||||
|
// configuration turns the guard off wholesale.
|
||||||
|
type Guard struct {
|
||||||
|
// allowed is the operator's ALLOWED_EGRESS_CIDRS. Empty
|
||||||
|
// (the default) means the default blocklist stands as-is.
|
||||||
|
allowed []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGuard builds the process-wide SSRF guard from configuration.
|
||||||
|
func NewGuard(cfg *config.Config) *Guard {
|
||||||
|
return &Guard{allowed: cfg.AllowedEgressCIDRs}
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateTargetURL checks that an HTTP delivery target
|
// ValidateTargetURL checks that an HTTP delivery target
|
||||||
// URL is safe from SSRF attacks.
|
// URL is safe from SSRF attacks.
|
||||||
func ValidateTargetURL(
|
func (g *Guard) ValidateTargetURL(
|
||||||
ctx context.Context, targetURL string,
|
ctx context.Context, targetURL string,
|
||||||
) error {
|
) error {
|
||||||
parsed, err := url.Parse(targetURL)
|
parsed, err := url.Parse(targetURL)
|
||||||
@@ -111,36 +275,79 @@ func ValidateTargetURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
return checkBlockedIP(ip)
|
return g.checkIP(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
return validateHostname(ctx, host)
|
return g.validateHostname(ctx, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateScheme(scheme string) error {
|
// NewSSRFSafeTransport creates an http.Transport with a
|
||||||
if scheme != "http" && scheme != "https" {
|
// custom DialContext that refuses connections to any address
|
||||||
|
// this guard blocks. It resolves and checks at dial time, so a
|
||||||
|
// name that passed validation but now answers with a blocked
|
||||||
|
// address (DNS rebinding) is still refused.
|
||||||
|
func (g *Guard) NewSSRFSafeTransport() *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
DialContext: g.ssrfDialContext,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allows reports whether ip falls inside the operator's
|
||||||
|
// configured egress allowlist.
|
||||||
|
func (g *Guard) allows(ip net.IP) bool {
|
||||||
|
if len(g.allowed) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config unmaps every parsed prefix, so an IPv4-mapped
|
||||||
|
// address has to be unmapped too or it would never match.
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
for _, prefix := range g.allowed {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIP is the single point at which SSRF policy is decided.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
|
// network becomes reachable.
|
||||||
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"unsupported URL scheme %q: %w",
|
"target IP %s: %w", ip, errBlockedMetadata,
|
||||||
scheme, errInvalidScheme,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if g.allows(ip) {
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func checkBlockedIP(ip net.IP) error {
|
|
||||||
if isBlockedIP(ip) {
|
if isBlockedIP(ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s is in a blocked "+
|
"target IP %s: %w", ip, errBlockedIP,
|
||||||
"private/reserved range: %w",
|
|
||||||
ip, errBlockedIP,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHostname(
|
func (g *Guard) validateHostname(
|
||||||
ctx context.Context, host string,
|
ctx context.Context, host string,
|
||||||
) error {
|
) error {
|
||||||
dnsCtx, cancel := context.WithTimeout(
|
dnsCtx, cancel := context.WithTimeout(
|
||||||
@@ -165,11 +372,11 @@ func validateHostname(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ipAddr := range ips {
|
for _, ipAddr := range ips {
|
||||||
if isBlockedIP(ipAddr.IP) {
|
err = g.checkIP(ipAddr.IP)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"hostname %q resolves to blocked "+
|
"hostname %q resolves to a blocked address: %w",
|
||||||
"IP %s: %w",
|
host, err,
|
||||||
host, ipAddr.IP, errBlockedIP,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,16 +384,7 @@ func validateHostname(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSSRFSafeTransport creates an http.Transport with a
|
func (g *Guard) ssrfDialContext(
|
||||||
// custom DialContext that blocks connections to
|
|
||||||
// private/reserved IP addresses.
|
|
||||||
func NewSSRFSafeTransport() *http.Transport {
|
|
||||||
return &http.Transport{
|
|
||||||
DialContext: ssrfDialContext,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrfDialContext(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
network, addr string,
|
network, addr string,
|
||||||
) (net.Conn, error) {
|
) (net.Conn, error) {
|
||||||
@@ -209,11 +407,11 @@ func ssrfDialContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ipAddr := range ips {
|
for _, ipAddr := range ips {
|
||||||
if isBlockedIP(ipAddr.IP) {
|
err = g.checkIP(ipAddr.IP)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"ssrf: connection to %s (%s) "+
|
"ssrf: connection to %s blocked: %w",
|
||||||
"blocked: %w",
|
host, err,
|
||||||
host, ipAddr.IP, errBlockedIP,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,3 +423,14 @@ func ssrfDialContext(
|
|||||||
net.JoinHostPort(ips[0].IP.String(), port),
|
net.JoinHostPort(ips[0].IP.String(), port),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateScheme(scheme string) error {
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"unsupported URL scheme %q: %w",
|
||||||
|
scheme, errInvalidScheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addresses the SSRF tests in this package share.
|
||||||
|
const (
|
||||||
|
// metadataIP is the cloud instance metadata address, and
|
||||||
|
// metadataURL an endpoint on it. The guard must never reach
|
||||||
|
// either, whatever an operator lists.
|
||||||
|
metadataIP = "169.254.169.254"
|
||||||
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
|
// default, reachable only once an operator allowlists
|
||||||
|
// loopback.
|
||||||
|
loopbackHookURL = "http://127.0.0.1/hook"
|
||||||
|
|
||||||
|
// publicIP is an ordinary public address, which the guard
|
||||||
|
// permits with or without an allowlist.
|
||||||
|
publicIP = "93.184.216.34"
|
||||||
|
|
||||||
|
// allowAllIPv4 and allowAllIPv6 are the widest allowlist
|
||||||
|
// entries expressible: the whole internet, in each family.
|
||||||
|
// Nothing unconditionally blocked may be reachable under
|
||||||
|
// them.
|
||||||
|
allowAllIPv4 = "0.0.0.0/0"
|
||||||
|
allowAllIPv6 = "::/0"
|
||||||
|
|
||||||
|
// allowAllULA is the ordinary ULA block an operator lists to
|
||||||
|
// reach their own IPv6 network. Several providers park a
|
||||||
|
// metadata endpoint inside it.
|
||||||
|
allowAllULA = "fd00::/8"
|
||||||
|
|
||||||
|
// metadataRefusalClause is the part of the refusal that only
|
||||||
|
// alwaysBlockedNetworks produces. Asserting it, rather than
|
||||||
|
// the bare word "blocked", is what proves the unconditional
|
||||||
|
// set did the refusing and not the default blocklist.
|
||||||
|
metadataRefusalClause = "ALLOWED_EGRESS_CIDRS cannot open it"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGuardAllowlist_PermittedCIDRDelivers proves the escape
|
||||||
|
// hatch actually works end to end: with 127.0.0.0/8 allowed, the
|
||||||
|
// guard's own transport connects to a loopback server and gets a
|
||||||
|
// response back. The default guard, given the identical URL,
|
||||||
|
// refuses it — so the delivery succeeds because of the allowlist
|
||||||
|
// and nothing else.
|
||||||
|
func TestGuardAllowlist_PermittedCIDRDelivers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
// httptest listens on loopback, which the default blocklist
|
||||||
|
// covers: exactly the "forward to a service on this host"
|
||||||
|
// case the allowlist exists for.
|
||||||
|
requireLoopback(t, srv.URL)
|
||||||
|
|
||||||
|
guard := delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix("127.0.0.0/8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
guard.ValidateTargetURL(context.Background(), srv.URL),
|
||||||
|
"an allowlisted loopback target must pass validation",
|
||||||
|
)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: guard.NewSSRFSafeTransport(),
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost, srv.URL, nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t,
|
||||||
|
err, "an allowlisted loopback target must be deliverable",
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
|
// The same URL through the default guard must still fail, or
|
||||||
|
// this test would pass without the allowlist doing anything.
|
||||||
|
assert.Error(t,
|
||||||
|
delivery.NewTestGuard().ValidateTargetURL(
|
||||||
|
context.Background(), srv.URL,
|
||||||
|
),
|
||||||
|
"without the allowlist the same target must be refused",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardAllowlist_UnlistedPrivateStillRefused proves the
|
||||||
|
// allowlist grants only what it names. A guard that opens one
|
||||||
|
// private block must keep refusing every other one, at both the
|
||||||
|
// validation and the delivery entry point.
|
||||||
|
func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Only 10.1.0.0/16 is open — a narrow block inside a much
|
||||||
|
// wider private range, so the test can tell "permits the
|
||||||
|
// listed block" from "permits anything private".
|
||||||
|
guard := delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix("10.1.0.0/16"),
|
||||||
|
)
|
||||||
|
|
||||||
|
refused := []string{
|
||||||
|
"http://192.168.1.10/hook",
|
||||||
|
"http://172.16.0.1/hook",
|
||||||
|
loopbackHookURL,
|
||||||
|
"http://[fc00::1]/hook",
|
||||||
|
"http://100.64.0.1/hook",
|
||||||
|
// Private, adjacent to the allowed block, outside it.
|
||||||
|
"http://10.2.0.1/hook",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range refused {
|
||||||
|
t.Run(target, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := guard.ValidateTargetURL(
|
||||||
|
context.Background(), target,
|
||||||
|
)
|
||||||
|
require.Error(t,
|
||||||
|
err, "%s is not allowlisted and must be refused",
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
assert.Contains(t, err.Error(), "blocked")
|
||||||
|
|
||||||
|
assertDialRefused(t, guard, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The block that is listed must in fact be permitted, so the
|
||||||
|
// refusals above are selective rather than a guard that
|
||||||
|
// ignores its allowlist entirely.
|
||||||
|
assert.NoError(t,
|
||||||
|
guard.ValidateTargetURL(
|
||||||
|
context.Background(), "http://10.1.2.3/hook",
|
||||||
|
),
|
||||||
|
"the allowlisted block must be permitted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tt := range metadataAlwaysRefusedCases() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
guard := delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix(tt.allow),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := guard.ValidateTargetURL(
|
||||||
|
context.Background(), tt.target,
|
||||||
|
)
|
||||||
|
require.Error(t,
|
||||||
|
err,
|
||||||
|
"%s must stay blocked even though %s covers it",
|
||||||
|
tt.target, tt.allow,
|
||||||
|
)
|
||||||
|
assert.Contains(t,
|
||||||
|
err.Error(),
|
||||||
|
metadataRefusalClause,
|
||||||
|
"the refusal must say why it cannot be opened",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The metadata clause, not just "blocked": that is
|
||||||
|
// what distinguishes the unconditional set from the
|
||||||
|
// ordinary blocklist.
|
||||||
|
assertDialRefusedWith(
|
||||||
|
t, guard, tt.target, metadataRefusalClause,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataAlwaysRefusedCase is one (allowlist, target) pair that
|
||||||
|
// must be refused: allow covers target, and target must stay
|
||||||
|
// blocked regardless.
|
||||||
|
type metadataAlwaysRefusedCase struct {
|
||||||
|
name string
|
||||||
|
allow string
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
cases := linkLocalRefusedCases()
|
||||||
|
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||||
|
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||||
|
|
||||||
|
return append(cases, encodedMetadataRefusedCases()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
|
// an operator naming the metadata address outright.
|
||||||
|
func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
return []metadataAlwaysRefusedCase{
|
||||||
|
{
|
||||||
|
name: "exact metadata host",
|
||||||
|
allow: "169.254.169.254/32",
|
||||||
|
target: metadataURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whole link-local block",
|
||||||
|
allow: "169.254.0.0/16",
|
||||||
|
target: metadataURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "supernet covering link-local",
|
||||||
|
allow: "169.0.0.0/8",
|
||||||
|
target: metadataURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the entire IPv4 internet",
|
||||||
|
allow: allowAllIPv4,
|
||||||
|
target: metadataURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other link-local address",
|
||||||
|
allow: allowAllIPv4,
|
||||||
|
target: "http://169.254.1.1/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 link-local",
|
||||||
|
allow: allowAllIPv6,
|
||||||
|
target: "http://[fe80::1]/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ulaMetadataRefusedCases covers the metadata endpoints parked
|
||||||
|
// in ULA space. Every one is opened by the single ordinary
|
||||||
|
// allowlist entry fd00::/8, which is the whole reason they need
|
||||||
|
// their own /128 host routes: fe80::/10 does not cover a ULA,
|
||||||
|
// whatever the vendor's documentation calls the address.
|
||||||
|
func ulaMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
return []metadataAlwaysRefusedCase{
|
||||||
|
{
|
||||||
|
name: "AWS IPv6 IMDS under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd00:ec2::254]/latest/meta-data/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A second AWS credential endpoint, distinct from
|
||||||
|
// IMDS. AWS's own docs call this one "localhost".
|
||||||
|
name: "AWS EKS Pod Identity under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd00:ec2::23]/v1/credentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GCP IPv6 metadata under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd20:ce::254]/computeMetadata/v1/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Oracle OCI IPv6 IMDS under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd00:c1::a9fe:a9fe]/opc/v2/instance/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scaleway IPv6 metadata under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd00:42::42]/conf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Akamai's docs call this "link-local"; it is a ULA,
|
||||||
|
// so fe80::/10 does not cover it.
|
||||||
|
name: "Linode IPv6 metadata under an allowlisted ULA block",
|
||||||
|
allow: allowAllULA,
|
||||||
|
target: "http://[fd00:a9fe:a9fe::1]/v1/instance",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv4MetadataRefusedCases covers the IPv4 metadata endpoints
|
||||||
|
// that sit outside link-local: one in CGNAT and one in the
|
||||||
|
// blocked 192.0.0.0/24, each reachable only through an allowlist
|
||||||
|
// that this set overrides.
|
||||||
|
func ipv4MetadataRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
return []metadataAlwaysRefusedCase{
|
||||||
|
{
|
||||||
|
// Tailscale uses 100.64.0.0/10, so an operator
|
||||||
|
// forwarding to a Tailscale peer lists exactly this.
|
||||||
|
name: "Alibaba metadata under allowlisted CGNAT",
|
||||||
|
allow: "100.64.0.0/10",
|
||||||
|
target: "http://100.100.100.200/latest/meta-data/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Inside the already-blocked 192.0.0.0/24, so only
|
||||||
|
// an allowlist can reach it — and must not.
|
||||||
|
name: "Oracle Cloud Classic metadata under 0.0.0.0/0",
|
||||||
|
allow: allowAllIPv4,
|
||||||
|
target: "http://192.0.0.192/latest/meta-data/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodedMetadataRefusedCases covers the alternate IPv6
|
||||||
|
// encodings of 169.254.169.254.
|
||||||
|
func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
return []metadataAlwaysRefusedCase{
|
||||||
|
{
|
||||||
|
// To4() does not normalise the IPv4-compatible form,
|
||||||
|
// so this needs its own always-blocked entry.
|
||||||
|
name: "IPv4-compatible IPv6 form of the metadata IP",
|
||||||
|
allow: allowAllIPv6,
|
||||||
|
target: "http://[::a9fe:a9fe]/latest/meta-data/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Nor the NAT64 well-known prefix form.
|
||||||
|
name: "NAT64 form of the metadata IP",
|
||||||
|
allow: allowAllIPv6,
|
||||||
|
target: "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Already refused before this change: IPNet.Contains
|
||||||
|
// calls To4() first, so the mapped form matches
|
||||||
|
// 169.254.0.0/16. Pinned so it cannot regress.
|
||||||
|
//
|
||||||
|
// Allowed under 0.0.0.0/0 rather than ::/0: allows()
|
||||||
|
// unmaps before matching, so ::/0 would not cover the
|
||||||
|
// unmapped v4 address and the case would not prove
|
||||||
|
// the allowlist was overridden.
|
||||||
|
name: "IPv4-mapped IPv6 form of the metadata IP",
|
||||||
|
allow: allowAllIPv4,
|
||||||
|
target: "http://[::ffff:169.254.169.254]/latest/meta-data/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestGuardAllowlist_PublicUnaffected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
guards := map[string]*delivery.Guard{
|
||||||
|
"default": delivery.NewTestGuard(),
|
||||||
|
"with allowlist": delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, guard := range guards {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.NoError(t,
|
||||||
|
guard.ValidateTargetURL(
|
||||||
|
context.Background(),
|
||||||
|
"http://"+publicIP+"/webhook",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardAllowlist_AzureWireServerReopenable covers Azure's
|
||||||
|
// WireServer, a public address that serves VM credentials. The
|
||||||
|
// default guard refuses it, but because it is public it sits in
|
||||||
|
// the default blocklist rather than the unconditional set, so an
|
||||||
|
// operator who lists it can reach it.
|
||||||
|
func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const wireServerIP = "168.63.129.16"
|
||||||
|
|
||||||
|
target := "http://" + wireServerIP + "/?comp=versions"
|
||||||
|
|
||||||
|
defaultGuard := delivery.NewTestGuard()
|
||||||
|
|
||||||
|
err := defaultGuard.ValidateTargetURL(context.Background(), target)
|
||||||
|
require.Error(t, err,
|
||||||
|
"WireServer must be refused with no allowlist set",
|
||||||
|
)
|
||||||
|
assert.NotContains(t, err.Error(), metadataRefusalClause,
|
||||||
|
"WireServer must be refused by the default blocklist, "+
|
||||||
|
"which an allowlist can override",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
|
listed := delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix(wireServerIP + "/32"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.NoError(t,
|
||||||
|
listed.ValidateTargetURL(context.Background(), target),
|
||||||
|
"an operator who lists WireServer must be able to reach it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardCheckIP_BothPathsShareOneDecision asserts that the
|
||||||
|
// validator and the dialer are not two policies that happen to
|
||||||
|
// agree: both are defined in terms of checkIP, so the exported
|
||||||
|
// decision function is the whole answer for a given address.
|
||||||
|
func TestGuardCheckIP_BothPathsShareOneDecision(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
guard := delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"10.1.2.3", true},
|
||||||
|
{publicIP, true},
|
||||||
|
{"192.168.1.1", false},
|
||||||
|
{"127.0.0.1", false},
|
||||||
|
{metadataIP, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.ip, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tt.ip)
|
||||||
|
require.NotNil(t, ip)
|
||||||
|
|
||||||
|
decision := guard.ExportCheckIP(ip)
|
||||||
|
|
||||||
|
validation := guard.ValidateTargetURL(
|
||||||
|
context.Background(), "http://"+hostFor(tt.ip)+"/x",
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.allowed {
|
||||||
|
require.NoError(t, decision)
|
||||||
|
require.NoError(t, validation)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, decision)
|
||||||
|
require.Error(t, validation,
|
||||||
|
"validation must refuse what checkIP refuses",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlwaysBlockedNetworks_PinnedSet pins the unconditional set
|
||||||
|
// exactly, so it cannot quietly grow or shrink.
|
||||||
|
//
|
||||||
|
// It stays deliberately small. Everything else in the default
|
||||||
|
// blocklist is an operator's own network and must remain
|
||||||
|
// openable, or the escape hatch would not work — which is why
|
||||||
|
// the metadata endpoints outside the link-local range are host
|
||||||
|
// routes rather than the blocks that contain them.
|
||||||
|
func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nets := delivery.ExportAlwaysBlockedNetworks()
|
||||||
|
|
||||||
|
got := make([]string, 0, len(nets))
|
||||||
|
for _, n := range nets {
|
||||||
|
got = append(got, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
|
// service on AWS, Azure and others.
|
||||||
|
"169.254.0.0/16",
|
||||||
|
// IPv6 link-local.
|
||||||
|
"fe80::/10",
|
||||||
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
|
// legitimately allowlist.
|
||||||
|
"fd00:ec2::254/128",
|
||||||
|
// AWS EKS Pod Identity Agent, likewise ULA.
|
||||||
|
"fd00:ec2::23/128",
|
||||||
|
// GCP metadata for IPv6-only instances, likewise ULA.
|
||||||
|
"fd20:ce::254/128",
|
||||||
|
// Oracle OCI IMDS over IPv6, likewise ULA.
|
||||||
|
"fd00:c1::a9fe:a9fe/128",
|
||||||
|
// Scaleway metadata over IPv6, likewise ULA.
|
||||||
|
"fd00:42::42/128",
|
||||||
|
// Linode/Akamai metadata over IPv6, likewise ULA.
|
||||||
|
"fd00:a9fe:a9fe::1/128",
|
||||||
|
// Alibaba Cloud metadata, inside CGNAT.
|
||||||
|
"100.100.100.200/32",
|
||||||
|
// Oracle Cloud Classic metadata, inside the blocked
|
||||||
|
// 192.0.0.0/24.
|
||||||
|
"192.0.0.192/32",
|
||||||
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
|
"::a9fe:a9fe/128",
|
||||||
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
|
"64:ff9b::a9fe:a9fe/128",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
|
// exercising a blocked range.
|
||||||
|
func requireLoopback(t *testing.T, rawURL string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ip := net.ParseIP(parsed.Hostname())
|
||||||
|
require.NotNil(t, ip, "test server host must be an IP literal")
|
||||||
|
require.True(t, ip.IsLoopback(),
|
||||||
|
"test server must listen on loopback, got %s", ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDialRefused asserts the guard's transport refuses to
|
||||||
|
// connect to target, which is the delivery-time half of the
|
||||||
|
// policy. It never reaches the network: the guard checks the
|
||||||
|
// resolved address before dialling.
|
||||||
|
func assertDialRefused(
|
||||||
|
t *testing.T, guard *delivery.Guard, target string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assertDialRefusedWith(t, guard, target, "blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDialRefusedWith is assertDialRefused with the refusal
|
||||||
|
// text pinned. Callers testing the unconditional set pass
|
||||||
|
// metadataRefusalClause so the subtest cannot pass on an
|
||||||
|
// ordinary blocklist refusal instead.
|
||||||
|
func assertDialRefusedWith(
|
||||||
|
t *testing.T, guard *delivery.Guard, target, clause string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: guard.NewSSRFSafeTransport(),
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost, target, nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, err,
|
||||||
|
"delivery to %s must be refused by the dialer", target,
|
||||||
|
)
|
||||||
|
assert.Contains(t, err.Error(), clause,
|
||||||
|
"the refusal must come from the SSRF guard",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostFor renders an IP as it appears in a URL host, bracketing
|
||||||
|
// IPv6 literals.
|
||||||
|
func hostFor(ip string) string {
|
||||||
|
if net.ParseIP(ip).To4() == nil {
|
||||||
|
return "[" + ip + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip
|
||||||
|
}
|
||||||
@@ -31,10 +31,10 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
|||||||
{"192.168.0.1", "192.168.0.1", true},
|
{"192.168.0.1", "192.168.0.1", true},
|
||||||
{"192.168.255.255", "192.168.255.255", true},
|
{"192.168.255.255", "192.168.255.255", true},
|
||||||
{"169.254.0.1", "169.254.0.1", true},
|
{"169.254.0.1", "169.254.0.1", true},
|
||||||
{"169.254.169.254", "169.254.169.254", true},
|
{metadataIP, metadataIP, true},
|
||||||
{"8.8.8.8", "8.8.8.8", false},
|
{"8.8.8.8", "8.8.8.8", false},
|
||||||
{"1.1.1.1", "1.1.1.1", false},
|
{"1.1.1.1", "1.1.1.1", false},
|
||||||
{"93.184.216.34", "93.184.216.34", false},
|
{publicIP, publicIP, false},
|
||||||
{"::1", "::1", true},
|
{"::1", "::1", true},
|
||||||
{"fd00::1", "fd00::1", true},
|
{"fd00::1", "fd00::1", true},
|
||||||
{"fc00::1", "fc00::1", true},
|
{"fc00::1", "fc00::1", true},
|
||||||
@@ -72,12 +72,12 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
blockedURLs := []string{
|
blockedURLs := []string{
|
||||||
"http://127.0.0.1/hook",
|
loopbackHookURL,
|
||||||
"http://127.0.0.1:8080/hook",
|
"http://127.0.0.1:8080/hook",
|
||||||
"https://10.0.0.1/hook",
|
"https://10.0.0.1/hook",
|
||||||
"http://192.168.1.1/webhook",
|
"http://192.168.1.1/webhook",
|
||||||
"http://172.16.0.1/api",
|
"http://172.16.0.1/api",
|
||||||
"http://169.254.169.254/latest/meta-data/",
|
metadataURL,
|
||||||
"http://[::1]/hook",
|
"http://[::1]/hook",
|
||||||
"http://[fc00::1]/hook",
|
"http://[fc00::1]/hook",
|
||||||
"http://[fe80::1]/hook",
|
"http://[fe80::1]/hook",
|
||||||
@@ -88,7 +88,7 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
t.Run(u, func(t *testing.T) {
|
t.Run(u, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.Background(), u,
|
context.Background(), u,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
|
|||||||
t.Run(u, func(t *testing.T) {
|
t.Run(u, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.Background(), u,
|
context.Background(), u,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
|
|||||||
func TestValidateTargetURL_InvalidScheme(t *testing.T) {
|
func TestValidateTargetURL_InvalidScheme(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.Background(), "ftp://example.com/hook",
|
context.Background(), "ftp://example.com/hook",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func TestValidateTargetURL_InvalidScheme(t *testing.T) {
|
|||||||
func TestValidateTargetURL_EmptyHost(t *testing.T) {
|
func TestValidateTargetURL_EmptyHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.Background(), "http:///path",
|
context.Background(), "http:///path",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ func TestValidateTargetURL_EmptyHost(t *testing.T) {
|
|||||||
func TestValidateTargetURL_InvalidURL(t *testing.T) {
|
func TestValidateTargetURL_InvalidURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.Background(), "://invalid",
|
context.Background(), "://invalid",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,21 +23,51 @@ type ConfigField struct {
|
|||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deletedNameSuffix marks the name of a target that no longer
|
||||||
|
// exists. Deletes are soft and delivery history outlives the
|
||||||
|
// target, so the event log shows names of targets that are gone;
|
||||||
|
// an operator reading one needs to know it cannot be delivered
|
||||||
|
// to, replayed to, or configured.
|
||||||
|
const deletedNameSuffix = " (deleted)"
|
||||||
|
|
||||||
// TargetView is the display-safe projection of a target for
|
// TargetView is the display-safe projection of a target for
|
||||||
// the UI. It deliberately has no raw configuration field, so
|
// the UI. It deliberately has no raw configuration field, so
|
||||||
// no template — present or future — can render the stored
|
// no template — present or future — can render the stored
|
||||||
// blob.
|
// blob.
|
||||||
type TargetView struct {
|
type TargetView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
|
// Deleted reports that this target's row is soft deleted.
|
||||||
|
// Only views built for historical display carry it set:
|
||||||
|
// every other projection is of a live row.
|
||||||
|
Deleted bool
|
||||||
|
|
||||||
Type database.TargetType
|
Type database.TargetType
|
||||||
Active bool
|
Active bool
|
||||||
Config []ConfigField
|
Config []ConfigField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayName is the name to render, marked when the target has
|
||||||
|
// been deleted. Templates showing a name against historical data
|
||||||
|
// must use it rather than Name, which stays the stored name.
|
||||||
|
func (v TargetView) DisplayName() string {
|
||||||
|
if v.Deleted {
|
||||||
|
return v.Name + deletedNameSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.Name
|
||||||
|
}
|
||||||
|
|
||||||
// NewTargetViews projects targets for rendering, replacing
|
// NewTargetViews projects targets for rendering, replacing
|
||||||
// each stored configuration blob with named, display-safe
|
// each stored configuration blob with named, display-safe
|
||||||
// fields.
|
// fields.
|
||||||
|
//
|
||||||
|
// A soft-deleted row projects exactly as a live one does, minus
|
||||||
|
// the deleted marker on its name: masking is a property of the
|
||||||
|
// projection, not of the row's state, so a deleted target's
|
||||||
|
// credential is as unreachable from a template as a live
|
||||||
|
// target's.
|
||||||
func NewTargetViews(
|
func NewTargetViews(
|
||||||
targets []database.Target,
|
targets []database.Target,
|
||||||
) []TargetView {
|
) []TargetView {
|
||||||
@@ -47,11 +77,12 @@ func NewTargetViews(
|
|||||||
t := &targets[i]
|
t := &targets[i]
|
||||||
|
|
||||||
views = append(views, TargetView{
|
views = append(views, TargetView{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Type: t.Type,
|
Deleted: t.DeletedAt.Valid,
|
||||||
Active: t.Active,
|
Type: t.Type,
|
||||||
Config: targetConfigFields(t),
|
Active: t.Active,
|
||||||
|
Config: targetConfigFields(t),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,14 @@ const (
|
|||||||
slackWebhookURL = "https://hooks.slack.com" +
|
slackWebhookURL = "https://hooks.slack.com" +
|
||||||
slackSecretPath
|
slackSecretPath
|
||||||
|
|
||||||
|
// slackMaskedURL is what a Slack webhook URL renders as
|
||||||
|
// once masked: scheme and host, path elided.
|
||||||
|
slackMaskedURL = "https://hooks.slack.com/..."
|
||||||
|
|
||||||
|
// slackTargetName is the target name the Slack projection
|
||||||
|
// tests use.
|
||||||
|
slackTargetName = "slack-target"
|
||||||
|
|
||||||
viewExampleOrigin = "https://example.com"
|
viewExampleOrigin = "https://example.com"
|
||||||
viewExampleHook = viewExampleOrigin + "/hook"
|
viewExampleHook = viewExampleOrigin + "/hook"
|
||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
@@ -33,7 +43,7 @@ func TestMaskedWebhookURL(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"slack webhook": {
|
"slack webhook": {
|
||||||
url: slackWebhookURL,
|
url: slackWebhookURL,
|
||||||
want: "https://hooks.slack.com/...",
|
want: slackMaskedURL,
|
||||||
},
|
},
|
||||||
"query string dropped": {
|
"query string dropped": {
|
||||||
url: viewExampleOrigin + "/a?token=secret",
|
url: viewExampleOrigin + "/a?token=secret",
|
||||||
@@ -125,23 +135,61 @@ func viewFor(
|
|||||||
return views[0]
|
return views[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTargetViews_Slack(t *testing.T) {
|
// TestNewTargetViews_DeletedTarget proves the projection marks
|
||||||
|
// a soft-deleted target's name and masks its configuration by
|
||||||
|
// the same rules a live target's is. Delivery history outlives
|
||||||
|
// the target it names, so this projection is what an operator
|
||||||
|
// reads about a target that no longer exists.
|
||||||
|
func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
target := slackTarget()
|
||||||
Name: "slack-target",
|
target.DeletedAt = gorm.DeletedAt{
|
||||||
|
Time: time.Now(),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
view := viewFor(t, target)
|
||||||
|
|
||||||
|
assert.True(t, view.Deleted)
|
||||||
|
assert.Equal(t, slackTargetName, view.Name)
|
||||||
|
assert.Equal(
|
||||||
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
|
fieldMap(view.Config),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slackTarget is the live Slack target the projection tests
|
||||||
|
// share.
|
||||||
|
func slackTarget() database.Target {
|
||||||
|
return database.Target{
|
||||||
|
Name: slackTargetName,
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: `{"webhookUrl":"` +
|
Config: `{"webhookUrl":"` +
|
||||||
slackWebhookURL + `"}`,
|
slackWebhookURL + `"}`,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTargetViews_Slack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
view := viewFor(t, slackTarget())
|
||||||
|
|
||||||
|
assert.Equal(t, slackTargetName, view.Name)
|
||||||
|
|
||||||
|
// A live target is never marked, so the marker cannot
|
||||||
|
// reach a name that still exists.
|
||||||
|
assert.False(t, view.Deleted)
|
||||||
|
assert.Equal(t, slackTargetName, view.DisplayName())
|
||||||
|
|
||||||
assert.Equal(t, "slack-target", view.Name)
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{"Webhook URL": slackMaskedURL},
|
||||||
"Webhook URL": "https://hooks.slack.com/...",
|
|
||||||
},
|
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,7 +260,7 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://hooks.slack.com/...",
|
slackMaskedURL,
|
||||||
fields["Destination URL"],
|
fields["Destination URL"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,17 @@ func (t *databaseTarget) Deliver(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
t.eng.recordResult(
|
recErr := t.eng.recordResult(
|
||||||
webhookDB, d, 1, false, 0, "",
|
webhookDB, d, 1, false, 0, "",
|
||||||
err.Error(), elapsed.Milliseconds(),
|
err.Error(), elapsed.Milliseconds(),
|
||||||
)
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
t.eng.bookkeepingFailed(d, recErr)
|
||||||
|
|
||||||
t.eng.updateDeliveryStatus(
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
@@ -71,12 +76,17 @@ func (t *databaseTarget) Deliver(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.eng.recordResult(
|
recErr := t.eng.recordResult(
|
||||||
webhookDB, d, 1, true, 0, "", "",
|
webhookDB, d, 1, true, 0, "", "",
|
||||||
elapsed.Milliseconds(),
|
elapsed.Milliseconds(),
|
||||||
)
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
t.eng.bookkeepingFailed(d, recErr)
|
||||||
|
|
||||||
t.eng.updateDeliveryStatus(
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -267,6 +277,24 @@ func (t *databaseTarget) evict(webhookID string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictAll evicts every cached archive writer, exactly as evict
|
||||||
|
// does for one webhook. 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.
|
||||||
|
func (t *databaseTarget) evictAll() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
writers := t.writers
|
||||||
|
t.writers = nil
|
||||||
|
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
for _, w := range writers {
|
||||||
|
w.evict()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sweepWebhook prunes one webhook's archive of rows older than
|
// sweepWebhook prunes one webhook's archive of rows older than
|
||||||
// expiry, without requiring a write. It returns nil (nothing to
|
// expiry, without requiring a write. It returns nil (nothing to
|
||||||
// do) when the archive file does not exist, so a sweep never
|
// do) when the archive file does not exist, so a sweep never
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ const (
|
|||||||
// path: open the archive file, creating it if missing, so a
|
// path: open the archive file, creating it if missing, so a
|
||||||
// first write (or a write after the operator moved the file
|
// first write (or a write after the operator moved the file
|
||||||
// away) recreates it.
|
// away) recreates it.
|
||||||
archiveModeCreate = "rwc"
|
archiveModeCreate = database.SQLiteModeCreate
|
||||||
|
|
||||||
// archiveModeExisting is the SQLite URI mode used by the idle
|
// archiveModeExisting is the SQLite URI mode used by the idle
|
||||||
// sweep: open read-write but never create. A sweep must never
|
// sweep: open read-write but never create. A sweep must never
|
||||||
// conjure an empty archive file for a webhook that has a
|
// conjure an empty archive file for a webhook that has a
|
||||||
// database target but has never received an event.
|
// database target but has never received an event.
|
||||||
archiveModeExisting = "rw"
|
archiveModeExisting = database.SQLiteModeExisting
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -273,9 +273,11 @@ func (w *archiveWriter) open(expiry time.Duration) error {
|
|||||||
func (w *archiveWriter) openMode(
|
func (w *archiveWriter) openMode(
|
||||||
mode string, expiry time.Duration,
|
mode string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
dbURL := fmt.Sprintf("file:%s?mode=%s", w.path, mode)
|
// Opened through database.OpenSQLite so an archive file carries
|
||||||
|
// the same WAL journaling, busy timeout, immediate-transaction
|
||||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
// locking, and pool bounds as every other database file. See
|
||||||
|
// internal/database/sqlite_open.go.
|
||||||
|
sqlDB, err := database.OpenSQLite(w.path, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"opening archive database %s: %w", w.path, err,
|
"opening archive database %s: %w", w.path, err,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -361,3 +362,46 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
|||||||
"a later delivery should recreate the writer",
|
"a later delivery should recreate the writer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEngineStop_WriteAfterStopIsRefused proves the engine's stop
|
||||||
|
// closes each archive writer the way deleting its webhook does: a
|
||||||
|
// write that reaches a writer after the stop is refused, reopens
|
||||||
|
// nothing and adds no row.
|
||||||
|
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
eng, _ := evictTestEngine(t)
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||||
|
|
||||||
|
eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
|
|
||||||
|
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
||||||
|
require.NotNil(t, w)
|
||||||
|
require.True(t, w.HandleOpen())
|
||||||
|
|
||||||
|
require.NoError(t, eng.ExportStop(context.Background()))
|
||||||
|
|
||||||
|
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
||||||
|
|
||||||
|
require.ErrorIs(
|
||||||
|
t, err, delivery.ErrExportArchiveWriterEvicted,
|
||||||
|
"a write after the stop must be refused",
|
||||||
|
)
|
||||||
|
assert.False(
|
||||||
|
t, w.HandleOpen(),
|
||||||
|
"a refused write must not reopen the archive",
|
||||||
|
)
|
||||||
|
assert.False(
|
||||||
|
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||||
|
"the stop should empty the registry",
|
||||||
|
)
|
||||||
|
|
||||||
|
count, err := countArchivedRows(w.Path())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1), count, "the refused row must not be written",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Literals these tests repeat, named so that the header name and the
|
// Literals these tests repeat, named so that the header names and the
|
||||||
// keep-forever archive config each have one definition.
|
// keep-forever archive config each have one definition.
|
||||||
const (
|
const (
|
||||||
headerAuthorization = "Authorization"
|
headerAuthorization = "Authorization"
|
||||||
|
headerContentType = "Content-Type"
|
||||||
bearerValue = "Bearer abc"
|
bearerValue = "Bearer abc"
|
||||||
archiveConfigNever = "{\"expiry\":\"never\"}"
|
archiveConfigNever = "{\"expiry\":\"never\"}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,14 +77,19 @@ func (c *httpCore) fireAndForget(
|
|||||||
) {
|
) {
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
webhookDB, d, 1, res.success,
|
webhookDB, d, 1, res.success,
|
||||||
res.statusCode, res.respBody, res.errMsg,
|
res.statusCode, res.respBody, res.errMsg,
|
||||||
res.duration,
|
res.duration,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.eng.bookkeepingFailed(d, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if res.success {
|
if res.success {
|
||||||
c.eng.updateDeliveryStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -92,7 +97,7 @@ func (c *httpCore) fireAndForget(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.eng.updateDeliveryStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
@@ -122,16 +127,25 @@ func (c *httpCore) withRetry(
|
|||||||
|
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
webhookDB, d, attemptNum, res.success,
|
webhookDB, d, attemptNum, res.success,
|
||||||
res.statusCode, res.respBody, res.errMsg,
|
res.statusCode, res.respBody, res.errMsg,
|
||||||
res.duration,
|
res.duration,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
// The breaker still learns the outcome: it describes the
|
||||||
|
// target's health, which is unaffected by this database's.
|
||||||
|
c.recordCircuitOutcome(cb, res.success)
|
||||||
|
|
||||||
|
c.eng.bookkeepingFailed(d, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if res.success {
|
if res.success {
|
||||||
cb.RecordSuccess()
|
cb.RecordSuccess()
|
||||||
|
|
||||||
c.eng.updateDeliveryStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -146,6 +160,20 @@ func (c *httpCore) withRetry(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordCircuitOutcome feeds one attempt's outcome to the target's
|
||||||
|
// circuit breaker.
|
||||||
|
func (c *httpCore) recordCircuitOutcome(
|
||||||
|
cb *CircuitBreaker, success bool,
|
||||||
|
) {
|
||||||
|
if success {
|
||||||
|
cb.RecordSuccess()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.RecordFailure()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *httpCore) circuitBreakerBlock(
|
func (c *httpCore) circuitBreakerBlock(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
@@ -169,10 +197,14 @@ func (c *httpCore) circuitBreakerBlock(
|
|||||||
"cooldown_remaining", remaining,
|
"cooldown_remaining", remaining,
|
||||||
)
|
)
|
||||||
|
|
||||||
c.eng.updateDeliveryStatus(
|
// A delivery already at retrying is left as it is, so a task
|
||||||
webhookDB, d, d.Target.Type,
|
// the breaker keeps turning away writes nothing each time.
|
||||||
database.DeliveryStatusRetrying,
|
if d.Status != database.DeliveryStatusRetrying {
|
||||||
)
|
c.eng.settleStatus(
|
||||||
|
webhookDB, d, d.Target.Type,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
retryTask := *task
|
retryTask := *task
|
||||||
sched.ScheduleRetry(retryTask, remaining)
|
sched.ScheduleRetry(retryTask, remaining)
|
||||||
@@ -189,7 +221,7 @@ func (c *httpCore) handleRetry(
|
|||||||
attemptNum int,
|
attemptNum int,
|
||||||
) {
|
) {
|
||||||
if attemptNum >= maxRetries {
|
if attemptNum >= maxRetries {
|
||||||
c.eng.updateDeliveryStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
@@ -197,7 +229,7 @@ func (c *httpCore) handleRetry(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.eng.updateDeliveryStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusRetrying,
|
database.DeliveryStatusRetrying,
|
||||||
)
|
)
|
||||||
@@ -332,12 +364,17 @@ func (t *httpTarget) Deliver(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
t.eng.recordResult(
|
recErr := t.eng.recordResult(
|
||||||
webhookDB, d, task.AttemptNum,
|
webhookDB, d, task.AttemptNum,
|
||||||
false, 0, "", err.Error(), 0,
|
false, 0, "", err.Error(), 0,
|
||||||
)
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
t.eng.bookkeepingFailed(d, recErr)
|
||||||
|
|
||||||
t.eng.updateDeliveryStatus(
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
@@ -504,6 +541,11 @@ func isForwardableHeader(name string) bool {
|
|||||||
"Upgrade", "Proxy-Authorization",
|
"Upgrade", "Proxy-Authorization",
|
||||||
"Proxy-Connection", "Content-Length":
|
"Proxy-Connection", "Content-Length":
|
||||||
return false
|
return false
|
||||||
|
case "Content-Type":
|
||||||
|
// applyRequestHeaders sets Content-Type itself. The receiver
|
||||||
|
// already stored this inbound value as the event's
|
||||||
|
// ContentType, so forwarding it too would send it twice.
|
||||||
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -516,6 +558,10 @@ func isForwardableHeader(name string) bool {
|
|||||||
// policy strips exactly that set on a hop that leaves the origin,
|
// policy strips exactly that set on a hop that leaves the origin,
|
||||||
// so the forward set is decided here and only here — a header added
|
// so the forward set is decided here and only here — a header added
|
||||||
// to it is covered off-origin without a second edit elsewhere.
|
// to it is covered off-origin without a second edit elsewhere.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
func applyRequestHeaders(
|
func applyRequestHeaders(
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -536,12 +582,18 @@ func applyRequestHeaders(
|
|||||||
|
|
||||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
// Content-Type describes the body being sent rather than the
|
// A Content-Type configured on the target describes the body
|
||||||
// sender, and the delivery path sets it from the event itself.
|
// being sent rather than the sender. A 307/308 preserves the
|
||||||
// A 307/308 preserves the body across hosts, so stripping it
|
// body across hosts, so stripping it would send that body
|
||||||
// would send that body untyped.
|
// untyped.
|
||||||
delete(originScoped, "Content-Type")
|
delete(originScoped, "Content-Type")
|
||||||
|
|
||||||
|
// User-Agent is overwritten just above, so an inbound one never
|
||||||
|
// reaches the wire and the value that does identifies this
|
||||||
|
// delivery path rather than the sender. Reporting it would strip
|
||||||
|
// it off-origin and leave net/http's own default in its place.
|
||||||
|
delete(originScoped, "User-Agent")
|
||||||
|
|
||||||
names := make([]string, 0, len(originScoped))
|
names := make([]string, 0, len(originScoped))
|
||||||
for name := range originScoped {
|
for name := range originScoped {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gitlabDeliverySecret is the shared secret the entrypoint in these
|
|
||||||
// tests is configured with. No outbound request may contain it.
|
|
||||||
const gitlabDeliverySecret = "QQDELIVERYSECRETQQ"
|
|
||||||
|
|
||||||
// receivedEventHeaders builds the Event.Headers value the receiver
|
|
||||||
// stores for an inbound request, by running the request's headers
|
|
||||||
// through the same sanitizer the receive path uses. Going through
|
|
||||||
// signature.SanitizeHeaders rather than a literal is the point of
|
|
||||||
// the test: it joins the two egresses at the field they share, so a
|
|
||||||
// regression at either end shows up here.
|
|
||||||
func receivedEventHeaders(
|
|
||||||
t *testing.T,
|
|
||||||
scheme database.SignatureScheme,
|
|
||||||
inbound http.Header,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
SignatureScheme: scheme,
|
|
||||||
SignatureSecret: gitlabDeliverySecret,
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := json.Marshal(
|
|
||||||
signature.SanitizeHeaders(ep, inbound),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return string(encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyRequestHeadersDropsInboundCredential proves a delivery to
|
|
||||||
// an HTTP target does not carry the GitLab shared secret.
|
|
||||||
//
|
|
||||||
// isForwardableHeader is a blocklist of hop-by-hop names, so it
|
|
||||||
// forwards X-Gitlab-Token like any other header; what keeps the
|
|
||||||
// secret out of the outbound request is that the receiver never
|
|
||||||
// stored it. Handing a target operator the token would hand them the
|
|
||||||
// ability to forge requests to the entrypoint it authenticates,
|
|
||||||
// which is the one control the receiver has.
|
|
||||||
func TestApplyRequestHeadersDropsInboundCredential(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
inbound := http.Header{}
|
|
||||||
inbound.Set(signature.HeaderGitLab, gitlabDeliverySecret)
|
|
||||||
inbound.Set("X-Gitlab-Event", "Push Hook")
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
Headers: receivedEventHeaders(
|
|
||||||
t, database.SignatureSchemeGitLab, inbound,
|
|
||||||
),
|
|
||||||
ContentType: "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"https://target.example.com/hook",
|
|
||||||
http.NoBody,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
delivery.ExportApplyRequestHeaders(
|
|
||||||
req, event, &delivery.HTTPTargetConfig{},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Empty(
|
|
||||||
t,
|
|
||||||
req.Header.Values(signature.HeaderGitLab),
|
|
||||||
"the shared secret header must not reach a target",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Header.Values canonicalises, so a differently-cased spelling
|
|
||||||
// would be caught above; this catches the value arriving under
|
|
||||||
// some other name.
|
|
||||||
for name, values := range req.Header {
|
|
||||||
for _, v := range values {
|
|
||||||
assert.NotContains(
|
|
||||||
t, v, gitlabDeliverySecret,
|
|
||||||
"secret present in outbound header %s", name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rest of the sender's headers still arrive. A fix that
|
|
||||||
// dropped everything would pass the assertions above while
|
|
||||||
// breaking delivery.
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
"Push Hook",
|
|
||||||
req.Header.Get("X-Gitlab-Event"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyRequestHeadersKeepsGitHubDigest proves the stripping is
|
|
||||||
// scoped to headers that carry the secret itself. GitHub's
|
|
||||||
// X-Hub-Signature-256 is an HMAC over the body, so a target can be
|
|
||||||
// shown it without being handed the key.
|
|
||||||
func TestApplyRequestHeadersKeepsGitHubDigest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const digest = "sha256=deadbeef"
|
|
||||||
|
|
||||||
inbound := http.Header{}
|
|
||||||
inbound.Set(signature.HeaderGitHub, digest)
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
Headers: receivedEventHeaders(
|
|
||||||
t, database.SignatureSchemeGitHub, inbound,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"https://target.example.com/hook",
|
|
||||||
http.NoBody,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
delivery.ExportApplyRequestHeaders(
|
|
||||||
req, event, &delivery.HTTPTargetConfig{},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, digest, req.Header.Get(signature.HeaderGitHub),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -55,12 +55,17 @@ func (t *logTarget) Deliver(
|
|||||||
|
|
||||||
t.eng.observeAttempt(d.Target.Type, elapsed)
|
t.eng.observeAttempt(d.Target.Type, elapsed)
|
||||||
|
|
||||||
t.eng.recordResult(
|
err := t.eng.recordResult(
|
||||||
webhookDB, d, 1, true, 0, "", "",
|
webhookDB, d, 1, true, 0, "", "",
|
||||||
elapsed.Milliseconds(),
|
elapsed.Milliseconds(),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.eng.bookkeepingFailed(d, err)
|
||||||
|
|
||||||
t.eng.updateDeliveryStatus(
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,12 +95,17 @@ func (t *slackTarget) failConfig(
|
|||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
t.eng.recordResult(
|
recErr := t.eng.recordResult(
|
||||||
webhookDB, d, 1,
|
webhookDB, d, 1,
|
||||||
false, 0, "", err.Error(), 0,
|
false, 0, "", err.Error(), 0,
|
||||||
)
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
t.eng.bookkeepingFailed(d, recErr)
|
||||||
|
|
||||||
t.eng.updateDeliveryStatus(
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
@@ -226,10 +231,15 @@ func FormatSlackMessage(
|
|||||||
event.ContentType,
|
event.ContentType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
timestamp := "unknown"
|
||||||
|
if !event.CreatedAt.IsZero() {
|
||||||
|
timestamp = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
&b,
|
&b,
|
||||||
"*Timestamp:* `%s`\n",
|
"*Timestamp:* `%s`\n",
|
||||||
event.CreatedAt.UTC().Format(time.RFC3339),
|
timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
|
|||||||
@@ -0,0 +1,792 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The two terminal-state gaps of
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/107: a delivery failed
|
||||||
|
// with nothing in its event log to say why, and a retrying delivery
|
||||||
|
// whose target was deleted, which used to keep sending and then never
|
||||||
|
// terminalise. Section 4 is the same deleted-target gap for a pending
|
||||||
|
// delivery: https://git.eeqj.de/sneak/webhooker/issues/293.
|
||||||
|
|
||||||
|
// tUnknownType is a target type no build implements. It stands in for
|
||||||
|
// a target whose type was written by a build that knew a type this one
|
||||||
|
// does not.
|
||||||
|
const tUnknownType = database.TargetType("pubsub")
|
||||||
|
|
||||||
|
// tSeedDeletedTarget creates a target, a delivery against it at the
|
||||||
|
// given status with one recorded failed attempt, and then deletes the
|
||||||
|
// target the way the source page does.
|
||||||
|
//
|
||||||
|
// It asserts the delete is soft, because that is the whole reason the
|
||||||
|
// engine could not tell a deleted target from a target id that never
|
||||||
|
// named a row: the surviving row is invisible to a scoped read.
|
||||||
|
func tSeedDeletedTarget(
|
||||||
|
t *testing.T,
|
||||||
|
s iSetup,
|
||||||
|
name, url string,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, name,
|
||||||
|
database.TargetTypeHTTP, iHTTPConfig(url), 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"target":"deleted"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID, status,
|
||||||
|
)
|
||||||
|
|
||||||
|
iSeedFailedResult(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.Delete(
|
||||||
|
&database.Target{}, "id = ?", targetID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
var scoped, unscoped int64
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.
|
||||||
|
Model(&database.Target{}).
|
||||||
|
Where("id = ?", targetID).
|
||||||
|
Count(&scoped).Error)
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.Unscoped().
|
||||||
|
Model(&database.Target{}).
|
||||||
|
Where("id = ?", targetID).
|
||||||
|
Count(&unscoped).Error)
|
||||||
|
|
||||||
|
require.Zero(t, scoped,
|
||||||
|
"the deleted target is still visible to a scoped read",
|
||||||
|
)
|
||||||
|
require.Equal(t, int64(1), unscoped,
|
||||||
|
"the delete was hard, so this test proves nothing about "+
|
||||||
|
"the soft-delete case it exists for",
|
||||||
|
)
|
||||||
|
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// tLastResult returns a delivery's final recorded attempt, asserting
|
||||||
|
// the expected number of them.
|
||||||
|
func tLastResult(
|
||||||
|
t *testing.T,
|
||||||
|
s iSetup,
|
||||||
|
deliveryID string,
|
||||||
|
want int,
|
||||||
|
) database.DeliveryResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
results := iResults(t, s.WebhookDB, deliveryID)
|
||||||
|
require.Len(t, results, want)
|
||||||
|
|
||||||
|
return results[want-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. A failure with nothing recorded ---
|
||||||
|
|
||||||
|
func TestProcessDelivery_UnknownTargetType_RecordsWhy(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"unknown":"type"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
seeded := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
target := database.Target{
|
||||||
|
Name: "mystery",
|
||||||
|
Type: tUnknownType,
|
||||||
|
Config: iHTTPConfig("http://example.com/hook"),
|
||||||
|
}
|
||||||
|
target.ID = targetID
|
||||||
|
|
||||||
|
d := database.Delivery{
|
||||||
|
EventID: event.ID,
|
||||||
|
TargetID: targetID,
|
||||||
|
Status: database.DeliveryStatusPending,
|
||||||
|
Event: event,
|
||||||
|
Target: target,
|
||||||
|
}
|
||||||
|
d.ID = seeded.ID
|
||||||
|
|
||||||
|
body := event.Body
|
||||||
|
task := iTask(
|
||||||
|
seeded, event, s.WebhookID, targetID, "mystery",
|
||||||
|
target.Config, 0, 1, &body,
|
||||||
|
)
|
||||||
|
task.TargetType = tUnknownType
|
||||||
|
|
||||||
|
s.Engine.ExportProcessDelivery(
|
||||||
|
context.Background(), s.WebhookDB, &d, &task,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID, database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, d.ID, 1)
|
||||||
|
|
||||||
|
assert.False(t, last.Success)
|
||||||
|
assert.Equal(t, 1, last.AttemptNum)
|
||||||
|
assert.Contains(t, last.Error, string(tUnknownType),
|
||||||
|
"the recorded reason does not name the offending type",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. A retrying delivery whose target is gone ---
|
||||||
|
|
||||||
|
func TestRecoverSingleRetry_TargetDeleted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(
|
||||||
|
t, s.MainDB, s.WebhookID, "deleted-target-recovery",
|
||||||
|
)
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-on-recovery", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverWebhookDeliveries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, deliveryID, 2)
|
||||||
|
|
||||||
|
assert.False(t, last.Success)
|
||||||
|
assert.Equal(t, 2, last.AttemptNum)
|
||||||
|
assert.Contains(t, last.Error, "gone-on-recovery")
|
||||||
|
assert.Contains(t, last.Error, "was deleted")
|
||||||
|
|
||||||
|
assert.Empty(t, s.Engine.ExportRetryCh(),
|
||||||
|
"a delivery whose target is gone was rescheduled",
|
||||||
|
)
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld(),
|
||||||
|
"the terminal path leaked its ownership reference",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweepSingleRetry_TargetDeleted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(
|
||||||
|
t, s.MainDB, s.WebhookID, "deleted-target-sweep",
|
||||||
|
)
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-on-sweep", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Twice, because the bug was an error the sweep repeated every
|
||||||
|
// minute for the life of the database: the second sweep must
|
||||||
|
// find nothing left to do.
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, deliveryID, 2)
|
||||||
|
|
||||||
|
assert.Contains(t, last.Error, "gone-on-sweep")
|
||||||
|
assert.Contains(t, last.Error, "was deleted")
|
||||||
|
|
||||||
|
assert.Empty(t, s.Engine.ExportRetryCh())
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepSingleRetry_TargetNeverExisted covers the other half of the
|
||||||
|
// soft-delete distinction: an id with no row at all, deleted or
|
||||||
|
// otherwise, must not be reported as something the operator deleted.
|
||||||
|
func TestSweepSingleRetry_TargetNeverExisted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(
|
||||||
|
t, s.MainDB, s.WebhookID, "target-never-existed",
|
||||||
|
)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"target":"absent"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
iSeedFailedResult(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID, database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, d.ID, 2)
|
||||||
|
|
||||||
|
assert.Contains(t, last.Error, targetID)
|
||||||
|
assert.Contains(t, last.Error, "no longer exists")
|
||||||
|
assert.NotContains(t, last.Error, "was deleted",
|
||||||
|
"an id that never named a row was reported as a deletion",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailMissingTarget_WritesNoTargetRow holds the new terminal path
|
||||||
|
// to the same rule as the existing one: no target row, and so no
|
||||||
|
// plaintext target config, may be written into the per-webhook event
|
||||||
|
// database. See https://git.eeqj.de/sneak/webhooker/issues/206.
|
||||||
|
func TestFailMissingTarget_WritesNoTargetRow(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(
|
||||||
|
t, s.MainDB, s.WebhookID, "no-target-row-deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
hookURL := "https://hooks.slack.com/services/T00/B00/x"
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "credential-bearing", hookURL,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
var configs []string
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Table("targets").
|
||||||
|
Pluck("config", &configs).Error)
|
||||||
|
|
||||||
|
assert.Empty(t, configs,
|
||||||
|
"the deleted-target terminal path wrote a target row "+
|
||||||
|
"into the per-webhook event database",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. The scheduled retry chain ---
|
||||||
|
|
||||||
|
// tRetryChainSetup wires a counting sink and a retrying delivery
|
||||||
|
// against a live target pointing at it, and returns the task a
|
||||||
|
// scheduled retry would carry — config and all, snapshotted as
|
||||||
|
// ScheduleRetry snapshots it.
|
||||||
|
func tRetryChainSetup(
|
||||||
|
t *testing.T,
|
||||||
|
s iSetup,
|
||||||
|
name string,
|
||||||
|
hits *atomic.Int64,
|
||||||
|
) (delivery.Task, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
iCreateWebhook(t, s.MainDB, s.WebhookID, name)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, name,
|
||||||
|
database.TargetTypeHTTP, cfg, 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"chain":"retry"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
iSeedFailedResult(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
body := event.Body
|
||||||
|
|
||||||
|
return iTask(
|
||||||
|
d, event, s.WebhookID, targetID, name, cfg, 5, 2, &body,
|
||||||
|
), targetID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessRetryTask_TargetDeleted_MakesNoAttempt is the half the
|
||||||
|
// deployability audit found worse than filed: terminalising on
|
||||||
|
// recovery and sweep alone leaves the already-scheduled timer chain
|
||||||
|
// running, and it holds the target's configuration from before the
|
||||||
|
// deletion, so it goes on sending to a destination that was removed.
|
||||||
|
func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
var hits atomic.Int64
|
||||||
|
|
||||||
|
task, targetID := tRetryChainSetup(
|
||||||
|
t, s, "gone-mid-chain", &hits,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.Delete(
|
||||||
|
&database.Target{}, "id = ?", targetID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
s.Engine.ExportProcessRetryTask(
|
||||||
|
context.Background(), &task,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Zero(t, hits.Load(),
|
||||||
|
"a scheduled retry fired at a target the operator "+
|
||||||
|
"had already deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, task.DeliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, task.DeliveryID, 2)
|
||||||
|
|
||||||
|
assert.False(t, last.Success)
|
||||||
|
assert.Contains(t, last.Error, "was deleted")
|
||||||
|
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestProcessRetryTask_TargetPresent_StillDelivers(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
var hits atomic.Int64
|
||||||
|
|
||||||
|
task, _ := tRetryChainSetup(t, s, "still-there", &hits)
|
||||||
|
|
||||||
|
s.Engine.ExportProcessRetryTask(
|
||||||
|
context.Background(), &task,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1), hits.Load())
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, task.DeliveryID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessRetryTask_TargetUnreadable_StillDelivers pins the other
|
||||||
|
// half of the guard: only a target that is confirmed gone stops a
|
||||||
|
// retry. A main database that cannot be read is a transient fault, and
|
||||||
|
// a guard that abandoned deliveries on one would be a worse bug than
|
||||||
|
// the one it fixes.
|
||||||
|
func TestProcessRetryTask_TargetUnreadable_StillDelivers(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
var hits atomic.Int64
|
||||||
|
|
||||||
|
task, _ := tRetryChainSetup(t, s, "unreadable-main", &hits)
|
||||||
|
|
||||||
|
sqlDB, err := s.MainDB.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
s.Engine.ExportProcessRetryTask(
|
||||||
|
context.Background(), &task,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1), hits.Load(),
|
||||||
|
"a retry was abandoned because the main database "+
|
||||||
|
"could not be read, not because its target was gone",
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, task.DeliveryID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone is the
|
||||||
|
// same rule on the recovery path. A read failure that is not
|
||||||
|
// "record not found" must leave every retrying delivery of every
|
||||||
|
// webhook exactly as it was.
|
||||||
|
func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(
|
||||||
|
t, s.MainDB, s.WebhookID, "unreadable-on-recovery",
|
||||||
|
)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "healthy",
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
iHTTPConfig("http://example.com/hook"), 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"still":"retrying"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
iSeedFailedResult(t, s.WebhookDB, d.ID)
|
||||||
|
|
||||||
|
sqlDB, err := s.MainDB.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverRetryingDeliveries(
|
||||||
|
s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Len(t, iResults(t, s.WebhookDB, d.ID), 1,
|
||||||
|
"an unreadable main database produced a terminal "+
|
||||||
|
"failure row",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. A pending delivery whose target is gone ---
|
||||||
|
|
||||||
|
func TestRecoverPending_TargetDeleted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-while-pending", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverWebhookDeliveries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, deliveryID, 2)
|
||||||
|
|
||||||
|
assert.False(t, last.Success)
|
||||||
|
assert.Equal(t, 2, last.AttemptNum)
|
||||||
|
assert.Contains(t, last.Error, "gone-while-pending")
|
||||||
|
assert.Contains(t, last.Error, "was deleted")
|
||||||
|
|
||||||
|
assert.Empty(t, fDrain(s.Engine),
|
||||||
|
"a delivery whose target is gone was sent",
|
||||||
|
)
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld(),
|
||||||
|
"the terminal path leaked its ownership reference",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone: the
|
||||||
|
// terminal write takes ownership like every other recovery write, so a
|
||||||
|
// delivery the engine still holds is not failed underneath its worker.
|
||||||
|
func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-but-owned", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.True(t, s.Engine.ExportRetainDelivery(deliveryID))
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverWebhookDeliveries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Len(t, iResults(t, s.WebhookDB, deliveryID), 1,
|
||||||
|
"a delivery the engine owns was failed underneath it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailMissingTarget_LeavesASettledDeliveryAlone: the recovery paths
|
||||||
|
// read their batch before taking ownership, and a worker may send a
|
||||||
|
// delivery and let it go in between. The terminal write goes by the row
|
||||||
|
// as it is now, not as the batch read it.
|
||||||
|
func TestFailMissingTarget_LeavesASettledDeliveryAlone(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-after-sending", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
var batch database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.First(
|
||||||
|
&batch, "id = ?", deliveryID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
// A worker settles the delivery after the batch was read.
|
||||||
|
require.NoError(t, s.WebhookDB.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", deliveryID).
|
||||||
|
Update("status", database.DeliveryStatusDelivered).Error)
|
||||||
|
|
||||||
|
s.Engine.ExportFailMissingTarget(
|
||||||
|
s.WebhookDB, s.WebhookID, &batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Len(t, iResults(t, s.WebhookDB, deliveryID), 1,
|
||||||
|
"a delivery settled after the batch read was then failed",
|
||||||
|
)
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepPending_TargetDeleted sweeps twice over a batch that also
|
||||||
|
// holds a healthy stranded delivery. The one whose target is gone is
|
||||||
|
// failed once and then left alone; the healthy one is queued by the
|
||||||
|
// first sweep and not again by the second.
|
||||||
|
func TestSweepPending_TargetDeleted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
liveTargetID := uuid.New().String()
|
||||||
|
s := fSweepSetup(t, liveTargetID, "still-there")
|
||||||
|
|
||||||
|
deliveryID := tSeedDeletedTarget(
|
||||||
|
t, s, "gone-on-pending-sweep", "http://example.com/hook",
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, deliveryID)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"target":"live"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
healthy := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, liveTargetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
rAgePending(t, s.WebhookDB, healthy.ID)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||||
|
|
||||||
|
tasks := fDrain(s.Engine)
|
||||||
|
require.Len(t, tasks, 1,
|
||||||
|
"the first sweep did not queue the healthy delivery",
|
||||||
|
)
|
||||||
|
assert.Equal(t, healthy.ID, tasks[0].DeliveryID)
|
||||||
|
|
||||||
|
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||||
|
|
||||||
|
assert.Empty(t, fDrain(s.Engine),
|
||||||
|
"the second sweep queued a delivery again",
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, deliveryID,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
last := tLastResult(t, s, deliveryID, 2)
|
||||||
|
|
||||||
|
assert.Contains(t, last.Error, "gone-on-pending-sweep")
|
||||||
|
assert.Contains(t, last.Error, "was deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendRecoveredDeliveries_TargetMissingFromMap: the batch's target
|
||||||
|
// map is empty when its query failed, so every delivery in the batch is
|
||||||
|
// looked up on its own. A healthy one is sent to the target that lookup
|
||||||
|
// finds.
|
||||||
|
func TestSendRecoveredDeliveries_TargetMissingFromMap(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"map":"empty"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.ExportSendRecoveredDeliveries(
|
||||||
|
context.Background(), s.WebhookDB,
|
||||||
|
[]database.Delivery{d}, s.WebhookID,
|
||||||
|
map[string]database.Target{}, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := fDrain(s.Engine)
|
||||||
|
require.Len(t, tasks, 1,
|
||||||
|
"the healthy delivery was not queued exactly once",
|
||||||
|
)
|
||||||
|
assert.Equal(t, d.ID, tasks[0].DeliveryID)
|
||||||
|
assert.Equal(t, targetID, tasks[0].TargetID)
|
||||||
|
assert.Equal(t, database.TargetTypeLog, tasks[0].TargetType)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone: a failed
|
||||||
|
// read of the main database is not a deleted target. Restart recovery
|
||||||
|
// holds every pending delivery of the webhook in one batch, so failing
|
||||||
|
// on this would fail all of them.
|
||||||
|
func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "healthy",
|
||||||
|
database.TargetTypeLog, "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"still":"pending"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
sqlDB, err := s.MainDB.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverPendingDeliveries(
|
||||||
|
context.Background(), s.WebhookDB, s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, d.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, iResults(t, s.WebhookDB, d.ID),
|
||||||
|
"an unreadable main database produced a terminal "+
|
||||||
|
"failure row",
|
||||||
|
)
|
||||||
|
assert.Empty(t, fDrain(s.Engine))
|
||||||
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
|
|||||||
func TestValidateTargetURL_UnparsableURLIsMasked(t *testing.T) {
|
func TestValidateTargetURL_UnparsableURLIsMasked(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.NewTestGuard().ValidateTargetURL(
|
||||||
context.TODO(),
|
context.TODO(),
|
||||||
"https://hooks.slack.com"+maskSecretPath+"\n",
|
"https://hooks.slack.com"+maskSecretPath+"\n",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -453,3 +453,33 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
|||||||
"the issued cookie must carry an authenticated session",
|
"the issued cookie must carry an authenticated session",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ func (h *Handlers) finishReplay(
|
|||||||
// The page is read from the form rather than the query string:
|
// 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
|
// this is a POST, and its query string is what logs and Referer
|
||||||
// headers record.
|
// headers record.
|
||||||
if page := parseNonNegativeInt(
|
if page := pageOrFirst(
|
||||||
r.PostFormValue("page"),
|
r.PostFormValue("page"),
|
||||||
); page > 1 {
|
); page > 1 {
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
dest += "&page=" + strconv.Itoa(page)
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// submitEntrypointSecret posts the signature configuration form for
|
|
||||||
// an entrypoint and returns the recorder.
|
|
||||||
func submitEntrypointSecret(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
webhookID, entrypointID, scheme, secret string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("signature_scheme", scheme)
|
|
||||||
form.Set("secret", secret)
|
|
||||||
|
|
||||||
req := formRequest(
|
|
||||||
"/source/"+webhookID+"/entrypoints/"+
|
|
||||||
entrypointID+"/secret",
|
|
||||||
cookies,
|
|
||||||
form,
|
|
||||||
map[string]string{
|
|
||||||
paramSourceID: webhookID,
|
|
||||||
entrypointIDParam: entrypointID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.HandleEntrypointSecret().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// reloadEntrypoint reads an entrypoint back from the database,
|
|
||||||
// including the columns the model keeps out of JSON.
|
|
||||||
func reloadEntrypoint(
|
|
||||||
t *testing.T,
|
|
||||||
db *database.Database,
|
|
||||||
id string,
|
|
||||||
) database.Entrypoint {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var ep database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, db.DB().Where("id = ?", id).First(&ep).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrypointSecretSetRotateAndRemove walks the whole lifecycle
|
|
||||||
// the UI has to support: turning verification on, rotating the secret
|
|
||||||
// to a new value, and turning it back off.
|
|
||||||
func TestEntrypointSecretSetRotateAndRemove(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)
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
ep := seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID, database.SignatureSchemeNone, "",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set.
|
|
||||||
w := submitEntrypointSecret(
|
|
||||||
t, h, cookies, wh.ID, ep.ID, "github", inboundSecret,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
stored := reloadEntrypoint(t, db, ep.ID)
|
|
||||||
assert.Equal(
|
|
||||||
t, database.SignatureSchemeGitHub, stored.SignatureScheme,
|
|
||||||
)
|
|
||||||
assert.Equal(t, inboundSecret, stored.SignatureSecret)
|
|
||||||
assert.True(t, stored.SignatureConfigured())
|
|
||||||
|
|
||||||
// Rotate: a new secret and a different scheme in one submission.
|
|
||||||
// The new value is submitted with surrounding whitespace, the way
|
|
||||||
// a secret pasted out of a password manager arrives; storing that
|
|
||||||
// verbatim would make every later request fail verification with
|
|
||||||
// nothing visible on either side to explain it.
|
|
||||||
const rotated = "QQROTATEDSECRETQQ"
|
|
||||||
|
|
||||||
w = submitEntrypointSecret(
|
|
||||||
t, h, cookies, wh.ID, ep.ID, "gitlab", " "+rotated+"\t",
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
stored = reloadEntrypoint(t, db, ep.ID)
|
|
||||||
assert.Equal(
|
|
||||||
t, database.SignatureSchemeGitLab, stored.SignatureScheme,
|
|
||||||
)
|
|
||||||
assert.Equal(t, rotated, stored.SignatureSecret)
|
|
||||||
|
|
||||||
// Remove. The secret has to go with the scheme: a stored
|
|
||||||
// credential nothing reads is one more copy to leak.
|
|
||||||
w = submitEntrypointSecret(t, h, cookies, wh.ID, ep.ID, "", "")
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
stored = reloadEntrypoint(t, db, ep.ID)
|
|
||||||
assert.Equal(
|
|
||||||
t, database.SignatureSchemeNone, stored.SignatureScheme,
|
|
||||||
)
|
|
||||||
assert.Empty(t, stored.SignatureSecret)
|
|
||||||
assert.False(t, stored.SignatureConfigured())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrypointSecretRejectsBadInput proves the form cannot create a
|
|
||||||
// row the receiver would later have to refuse. Both rejections leave
|
|
||||||
// the stored configuration untouched rather than half-applied.
|
|
||||||
func TestEntrypointSecretRejectsBadInput(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
scheme string
|
|
||||||
secret string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unsupported scheme",
|
|
||||||
scheme: "stripe",
|
|
||||||
secret: inboundSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scheme with no secret",
|
|
||||||
scheme: "github",
|
|
||||||
secret: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Whitespace is stripped, so a secret of spaces is an
|
|
||||||
// empty one.
|
|
||||||
name: "scheme with blank secret",
|
|
||||||
scheme: "github",
|
|
||||||
secret: " ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
ep := seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.SignatureSchemeGitLab, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := submitEntrypointSecret(
|
|
||||||
t, h, cookies, wh.ID, ep.ID, tc.scheme, tc.secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusBadRequest, w.Code, "case %s", tc.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
stored := reloadEntrypoint(t, db, ep.ID)
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
database.SignatureSchemeGitLab,
|
|
||||||
stored.SignatureScheme,
|
|
||||||
"case %s", tc.name,
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, inboundSecret, stored.SignatureSecret,
|
|
||||||
"case %s", tc.name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrypointSecretRequiresOwnership proves the configuration
|
|
||||||
// endpoint is bound by the same ownership check as the rest of the
|
|
||||||
// webhook's pages: another user's entrypoint is a 404, and the secret
|
|
||||||
// is not touched.
|
|
||||||
func TestEntrypointSecretRequiresOwnership(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)
|
|
||||||
ep := seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.SignatureSchemeGitLab, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
stranger := authenticatedCookies(
|
|
||||||
t, sess, "someone-else", "someoneelse",
|
|
||||||
)
|
|
||||||
|
|
||||||
w := submitEntrypointSecret(
|
|
||||||
t, h, stranger, wh.ID, ep.ID, "github", "hijacked",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
inboundSecret,
|
|
||||||
reloadEntrypoint(t, db, ep.ID).SignatureSecret,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksEntrypointSecret is the regression test
|
|
||||||
// for the credential on the entrypoint: the page has to say that
|
|
||||||
// verification is configured and which header carries it, without the
|
|
||||||
// secret itself ever reaching the rendered HTML.
|
|
||||||
func TestHandleSourceDetail_MasksEntrypointSecret(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)
|
|
||||||
seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.SignatureSchemeGitHub, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.NotContains(t, body, inboundSecret)
|
|
||||||
assert.Contains(t, body, "GitHub")
|
|
||||||
assert.Contains(t, body, "X-Hub-Signature-256")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrypointViewsDropTheSecret pins the projection itself, so the
|
|
||||||
// barrier survives a template rewrite that stops rendering the field
|
|
||||||
// the page test above looks at.
|
|
||||||
func TestEntrypointViewsDropTheSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
views := handlers.NewEntrypointViews([]database.Entrypoint{
|
|
||||||
{
|
|
||||||
Path: "p1",
|
|
||||||
Active: true,
|
|
||||||
SignatureScheme: database.SignatureSchemeGitHub,
|
|
||||||
SignatureSecret: inboundSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Path: "p2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Half a configuration. The receiver 500s every request
|
|
||||||
// to this row, so the UI must not call it unverified.
|
|
||||||
Path: "p2a",
|
|
||||||
SignatureScheme: database.SignatureSchemeGitLab,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The other half.
|
|
||||||
Path: "p2b",
|
|
||||||
SignatureSecret: inboundSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A scheme this build does not know: described as
|
|
||||||
// unavailable, never echoed back.
|
|
||||||
Path: "p3",
|
|
||||||
SignatureScheme: database.SignatureScheme("stripe"),
|
|
||||||
SignatureSecret: inboundSecret,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Len(t, views, 5)
|
|
||||||
|
|
||||||
assert.True(t, views[0].Configured)
|
|
||||||
assert.Equal(t, "GitHub", views[0].SchemeLabel)
|
|
||||||
assert.Equal(t, "X-Hub-Signature-256", views[0].SchemeHeader)
|
|
||||||
|
|
||||||
assert.False(t, views[1].Configured)
|
|
||||||
assert.Equal(t, "not verified", views[1].SchemeLabel)
|
|
||||||
assert.Empty(t, views[1].SchemeHeader)
|
|
||||||
|
|
||||||
for _, v := range []handlers.EntrypointView{views[2], views[3]} {
|
|
||||||
assert.False(t, v.Configured)
|
|
||||||
assert.Equal(t, "misconfigured", v.SchemeLabel)
|
|
||||||
assert.Empty(t, v.SchemeHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, views[4].Configured)
|
|
||||||
assert.Equal(t, "(unavailable)", views[4].SchemeLabel)
|
|
||||||
|
|
||||||
// The struct has no field that could carry the secret, so this
|
|
||||||
// fails to compile rather than fails at runtime if one is added
|
|
||||||
// and populated. The assertion covers the labels it derives.
|
|
||||||
for _, v := range views {
|
|
||||||
assert.NotContains(t, v.SchemeLabel, inboundSecret)
|
|
||||||
assert.NotContains(t, v.SchemeHeader, inboundSecret)
|
|
||||||
assert.NotContains(t, string(v.Scheme), inboundSecret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,56 +2,18 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// signatureUnavailable is what an entrypoint's scheme renders as when
|
|
||||||
// the stored value is not one this build supports. The stored string
|
|
||||||
// is never echoed as a fallback: it is operator-supplied and the row
|
|
||||||
// is already in a state the receiver refuses, so the UI says so
|
|
||||||
// rather than inventing a description for it.
|
|
||||||
const signatureUnavailable = "(unavailable)"
|
|
||||||
|
|
||||||
// signatureNotVerified is the label for an entrypoint that performs
|
|
||||||
// no inbound verification.
|
|
||||||
const signatureNotVerified = "not verified"
|
|
||||||
|
|
||||||
// signatureMisconfigured is the label for a row holding one half of
|
|
||||||
// the scheme/secret pair. The receiver answers every request to such
|
|
||||||
// an entrypoint 500, so calling it "not verified" would describe a
|
|
||||||
// receiver that is refusing everything as one that is accepting
|
|
||||||
// everything. The form cannot create the state; a hand-edited
|
|
||||||
// database or a downgrade past a scheme can.
|
|
||||||
const signatureMisconfigured = "misconfigured"
|
|
||||||
|
|
||||||
// EntrypointView is the display-safe projection of an entrypoint for
|
// EntrypointView is the display-safe projection of an entrypoint for
|
||||||
// the UI. It deliberately has no secret field, so no template —
|
// the UI, in the same way delivery.TargetView is one for a target.
|
||||||
// present or future — can render the shared secret, in the same way
|
|
||||||
// delivery.TargetView keeps a target's stored credential away from
|
|
||||||
// one.
|
|
||||||
type EntrypointView struct {
|
type EntrypointView struct {
|
||||||
ID string
|
ID string
|
||||||
Path string
|
Path string
|
||||||
Description string
|
Description string
|
||||||
Active bool
|
Active bool
|
||||||
|
|
||||||
// Configured reports whether inbound requests to this entrypoint
|
|
||||||
// are verified.
|
|
||||||
Configured bool
|
|
||||||
|
|
||||||
// Scheme is the stored scheme, carried so the form can preselect
|
|
||||||
// it. It names an algorithm, not a secret.
|
|
||||||
Scheme database.SignatureScheme
|
|
||||||
|
|
||||||
// SchemeLabel and SchemeHeader describe the configured scheme for
|
|
||||||
// display: the sender's name, and the header its signature
|
|
||||||
// arrives in.
|
|
||||||
SchemeLabel string
|
|
||||||
SchemeHeader string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEntrypointViews projects entrypoints for rendering, dropping the
|
// NewEntrypointViews projects entrypoints for rendering.
|
||||||
// shared secret on the way.
|
|
||||||
func NewEntrypointViews(
|
func NewEntrypointViews(
|
||||||
entrypoints []database.Entrypoint,
|
entrypoints []database.Entrypoint,
|
||||||
) []EntrypointView {
|
) []EntrypointView {
|
||||||
@@ -60,31 +22,12 @@ func NewEntrypointViews(
|
|||||||
for i := range entrypoints {
|
for i := range entrypoints {
|
||||||
e := &entrypoints[i]
|
e := &entrypoints[i]
|
||||||
|
|
||||||
view := EntrypointView{
|
views = append(views, EntrypointView{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Path: e.Path,
|
Path: e.Path,
|
||||||
Description: e.Description,
|
Description: e.Description,
|
||||||
Active: e.Active,
|
Active: e.Active,
|
||||||
Configured: e.SignatureConfigured(),
|
})
|
||||||
Scheme: e.SignatureScheme,
|
|
||||||
SchemeLabel: signatureNotVerified,
|
|
||||||
SchemeHeader: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case view.Configured:
|
|
||||||
view.SchemeLabel = signatureUnavailable
|
|
||||||
|
|
||||||
info, ok := signature.Info(e.SignatureScheme)
|
|
||||||
if ok {
|
|
||||||
view.SchemeLabel = info.Label
|
|
||||||
view.SchemeHeader = info.Header
|
|
||||||
}
|
|
||||||
case e.SignatureHalfConfigured():
|
|
||||||
view.SchemeLabel = signatureMisconfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
views = append(views, view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return views
|
return views
|
||||||
|
|||||||
@@ -92,11 +92,10 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// once per range.
|
// once per range.
|
||||||
//
|
//
|
||||||
// One consequence is worth keeping in view: the read finishes
|
// One consequence is worth keeping in view: the read finishes
|
||||||
// before the client is written to, so no read lock is held for
|
// before the client is written to, so nothing is held open for
|
||||||
// the length of a slow download. These per-webhook databases
|
// the length of a slow download. Under WAL a read no longer
|
||||||
// run in SQLite's default journal mode rather than WAL, so a
|
// blocks the receiver, but it does pin the WAL against
|
||||||
// lock held that long would block the receiver from recording
|
// checkpointing, and a download can last minutes.
|
||||||
// new events.
|
|
||||||
func (h *Handlers) serveEventBody(
|
func (h *Handlers) serveEventBody(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const maxRenderedBodyBytes = 8192
|
|||||||
// rather than in Go is the point of the projection — an
|
// rather than in Go is the point of the projection — an
|
||||||
// oversized body never becomes a Go string at all.
|
// oversized body never becomes a Go string at all.
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
|
"resubmitted_from_id, " +
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
@@ -45,9 +46,25 @@ type EventLogView struct {
|
|||||||
// than the cap, so the page owes the reader a marker.
|
// than the cap, so the page owes the reader a marker.
|
||||||
BodyTruncated bool
|
BodyTruncated bool
|
||||||
|
|
||||||
|
// ResubmittedFromID names the event this one was copied
|
||||||
|
// from, empty for an event that arrived on the receiver.
|
||||||
|
ResubmittedFromID string
|
||||||
|
|
||||||
|
// ResubmitCount is how many events have been resubmitted
|
||||||
|
// from this one. Both directions are shown, because after
|
||||||
|
// a few resubmits of one captured event the log is
|
||||||
|
// otherwise a row of identical bodies with nothing saying
|
||||||
|
// which came from which.
|
||||||
|
ResubmitCount int
|
||||||
|
|
||||||
Deliveries []DeliveryView
|
Deliveries []DeliveryView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResubmittedFrom reports that this event is a copy of another.
|
||||||
|
func (v EventLogView) ResubmittedFrom() bool {
|
||||||
|
return v.ResubmittedFromID != ""
|
||||||
|
}
|
||||||
|
|
||||||
// BodyShownBytes is how many body bytes the page is actually
|
// BodyShownBytes is how many body bytes the page is actually
|
||||||
// rendering, which the truncation marker reports beside the
|
// rendering, which the truncation marker reports beside the
|
||||||
// true size.
|
// true size.
|
||||||
@@ -59,12 +76,13 @@ func (v EventLogView) BodyShownBytes() int {
|
|||||||
// body column arrives already cut to the cap by SQLite, with
|
// body column arrives already cut to the cap by SQLite, with
|
||||||
// the true size beside it.
|
// the true size beside it.
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
Body []byte
|
ResubmittedFromID *string
|
||||||
BodyBytes int64
|
Body []byte
|
||||||
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row for rendering.
|
// view projects a loaded row for rendering.
|
||||||
@@ -79,14 +97,20 @@ func (r *eventLogRow) view() EventLogView {
|
|||||||
body = trimPartialRune(body)
|
body = trimPartialRune(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var from string
|
||||||
|
if r.ResubmittedFromID != nil {
|
||||||
|
from = *r.ResubmittedFromID
|
||||||
|
}
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
Body: string(body),
|
Body: string(body),
|
||||||
BodyBytes: r.BodyBytes,
|
BodyBytes: r.BodyBytes,
|
||||||
BodyTruncated: truncated,
|
BodyTruncated: truncated,
|
||||||
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"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
|
||||||
|
|
||||||
|
const (
|
||||||
|
// resubmitQueued reports that a new event was stored and its
|
||||||
|
// deliveries handed to the delivery engine.
|
||||||
|
resubmitQueued resubmitOutcomeCode = "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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type resubmitSource struct {
|
||||||
|
ID string
|
||||||
|
EntrypointID string
|
||||||
|
Method string
|
||||||
|
Headers string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubmitColumns is the projection resubmitSource is loaded through.
|
||||||
|
// 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, " +
|
||||||
|
"content_type, cast(body as blob) AS body"
|
||||||
|
|
||||||
|
// HandleEventResubmit re-injects a stored event as a new undelivered
|
||||||
|
// event.
|
||||||
|
//
|
||||||
|
// This is the testing counterpart to per-delivery replay, and the two
|
||||||
|
// select targets differently on purpose. A replay re-sends ONE
|
||||||
|
// finished delivery to ITS OWN target, which is recovery. A resubmit
|
||||||
|
// stores a NEW event copied from the stored one and fans it out to the
|
||||||
|
// webhook's currently ACTIVE targets, resolved fresh by the query the
|
||||||
|
// receiver uses — so a target created after the original event arrived
|
||||||
|
// receives it, which is what makes capturing real traffic and firing
|
||||||
|
// it at a backend under development possible. The original event's
|
||||||
|
// deliveries have no bearing on where the copy goes.
|
||||||
|
//
|
||||||
|
// Nothing about the original delivery is re-sent: what is re-injected
|
||||||
|
// is the stored EVENT. The response bodies and headers the original
|
||||||
|
// deliveries received stay where they are.
|
||||||
|
//
|
||||||
|
// Resubmitting the same event repeatedly is supported and is the point
|
||||||
|
// of the feature, so replay's in-flight refusal is deliberately not
|
||||||
|
// applied here. The route's rate limit is what bounds a held-down
|
||||||
|
// button.
|
||||||
|
func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
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.resubmitEvent(w, r, webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubmitEvent performs the resubmit for a webhook the caller has
|
||||||
|
// already established the session's user owns.
|
||||||
|
func (h *Handlers) resubmitEvent(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
) {
|
||||||
|
// Parsing the id before use keeps a malformed id out of the SQL
|
||||||
|
// and makes the value the query sees come from uuid's own fixed
|
||||||
|
// alphabet rather than from the request.
|
||||||
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read before the write transaction is opened. The body can be up
|
||||||
|
// to the 1 MB ingest cap, and every transaction on these files
|
||||||
|
// takes the write lock at BEGIN (_txlock=immediate, see
|
||||||
|
// internal/database/sqlite_open.go), so reading inside it would
|
||||||
|
// hold that lock against the receiver for the length of the read.
|
||||||
|
src, found, err := loadResubmitSource(
|
||||||
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
|
// another webhook, or never existed.
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.queueResubmit(w, r, webhook, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadResubmitSource reads the stored event a resubmit copies, and
|
||||||
|
// whether it exists within the webhook.
|
||||||
|
//
|
||||||
|
// The webhook_id predicate is currently redundant against the
|
||||||
|
// 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.
|
||||||
|
func loadResubmitSource(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) (resubmitSource, bool, error) {
|
||||||
|
var src resubmitSource
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Event{}).
|
||||||
|
Select(resubmitColumns).
|
||||||
|
Where("id = ? AND webhook_id = ?", eventID, webhookID).
|
||||||
|
First(&src).Error
|
||||||
|
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return src, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return src, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return src, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueResubmit stores the copy and fans it out to the webhook's
|
||||||
|
// active targets.
|
||||||
|
func (h *Handlers) queueResubmit(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
src resubmitSource,
|
||||||
|
) {
|
||||||
|
// The receiver's own query, run now: an active target created
|
||||||
|
// after the original event arrived is included, and an
|
||||||
|
// inactive one is skipped rather than refused.
|
||||||
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event, tasks, err := h.createAndFanOut(
|
||||||
|
eventSource{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
EntrypointID: src.EntrypointID,
|
||||||
|
Method: src.Method,
|
||||||
|
HeadersJSON: src.Headers,
|
||||||
|
ContentType: src.ContentType,
|
||||||
|
Body: src.Body,
|
||||||
|
ResubmittedFromID: &src.ID,
|
||||||
|
},
|
||||||
|
targets,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mtr.EventResubmitted()
|
||||||
|
|
||||||
|
h.log.Info(
|
||||||
|
"event resubmitted",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"event_id", event.ID,
|
||||||
|
"resubmitted_from_id", src.ID,
|
||||||
|
"target_count", len(tasks),
|
||||||
|
)
|
||||||
|
|
||||||
|
code := resubmitQueued
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resubmitTargetURL is a public destination, so a target configured
|
||||||
|
// with it is one the SSRF guard would accept. Nothing in these tests
|
||||||
|
// 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"]}`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the operator has yet to create the target to test.
|
||||||
|
func seedStoredEvent(
|
||||||
|
t *testing.T,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhookID, body string,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: "entrypoint-" + webhookID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: resubmitEventHeaders,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentTypeJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(event).Error)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// postResubmit runs the real resubmit handler for one event.
|
||||||
|
func postResubmit(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||||
|
authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
),
|
||||||
|
map[string]string{
|
||||||
|
paramSourceID: webhookID,
|
||||||
|
paramEventID: eventID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleEventResubmit().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// listEvents reads every event in a webhook's database, oldest first.
|
||||||
|
func listEvents(
|
||||||
|
t *testing.T, webhookDB *gorm.DB,
|
||||||
|
) []database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var events []database.Event
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.
|
||||||
|
Order("created_at ASC, id ASC").
|
||||||
|
Find(&events).Error)
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// theOtherEvent returns the one event that is not excludeID.
|
||||||
|
func theOtherEvent(
|
||||||
|
t *testing.T, events []database.Event, excludeID string,
|
||||||
|
) database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var found []database.Event
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
if e.ID != excludeID {
|
||||||
|
found = append(found, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
|
||||||
|
return found[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent is the
|
||||||
|
// core of the feature and the thing per-delivery replay cannot do: the
|
||||||
|
// event was captured before the target existed, so it has no delivery
|
||||||
|
// to replay, and the resubmit must still reach the new target.
|
||||||
|
func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
original := seedStoredEvent(
|
||||||
|
t, dbMgr, wh.ID, `{"captured":"traffic"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dev backend is registered only now, after the traffic was
|
||||||
|
// captured. It has no prior delivery of anything.
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||||
|
w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(
|
||||||
|
t, events, 2,
|
||||||
|
"resubmit must create a new event, not mark the old one",
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh := theOtherEvent(t, events, original.ID)
|
||||||
|
assertEventCopy(t, original, fresh)
|
||||||
|
|
||||||
|
// The delivery hangs off the NEW event, and the original event
|
||||||
|
// still has none.
|
||||||
|
assert.Empty(
|
||||||
|
t, listDeliveries(t, webhookDB, original.ID),
|
||||||
|
"the original event must be left untouched",
|
||||||
|
)
|
||||||
|
|
||||||
|
deliveries := listDeliveries(t, webhookDB, fresh.ID)
|
||||||
|
require.Len(t, deliveries, 1)
|
||||||
|
assert.Equal(t, tgt.ID, deliveries[0].TargetID)
|
||||||
|
assert.Equal(
|
||||||
|
t, database.DeliveryStatusPending, deliveries[0].Status,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := notif.Tasks()
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
assert.Equal(t, deliveries[0].ID, tasks[0].DeliveryID)
|
||||||
|
assertResubmitTask(t, tasks[0], wh.ID, &fresh, tgt)
|
||||||
|
|
||||||
|
assertNoLeakedTarget(t, webhookDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEventCopy proves the resubmitted event copies every stored
|
||||||
|
// field of the event it came from, and records the provenance that
|
||||||
|
// keeps the log readable. It also pins that a received event carries
|
||||||
|
// no source event of its own.
|
||||||
|
func assertEventCopy(
|
||||||
|
t *testing.T, original *database.Event, fresh database.Event,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Equal(t, original.Method, fresh.Method)
|
||||||
|
assert.Equal(t, original.Headers, fresh.Headers)
|
||||||
|
assert.Equal(t, original.Body, fresh.Body)
|
||||||
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
||||||
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
||||||
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
||||||
|
assert.NotEqual(t, original.ID, fresh.ID)
|
||||||
|
|
||||||
|
require.NotNil(t, fresh.ResubmittedFromID)
|
||||||
|
assert.Equal(t, original.ID, *fresh.ResubmittedFromID)
|
||||||
|
|
||||||
|
assert.Nil(
|
||||||
|
t, original.ResubmittedFromID,
|
||||||
|
"a received event records no source event",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertResubmitTask proves the task handed to the delivery engine is
|
||||||
|
// the one the receiver would build for the NEW event and this target.
|
||||||
|
func assertResubmitTask(
|
||||||
|
t *testing.T,
|
||||||
|
task delivery.Task,
|
||||||
|
webhookID string,
|
||||||
|
fresh *database.Event,
|
||||||
|
target *database.Target,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Equal(t, fresh.ID, task.EventID)
|
||||||
|
assert.Equal(t, webhookID, task.WebhookID)
|
||||||
|
assert.Equal(t, fresh.EntrypointID, task.EntrypointID)
|
||||||
|
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.Headers, task.Headers)
|
||||||
|
assert.Equal(t, fresh.ContentType, task.ContentType)
|
||||||
|
assert.Equal(t, 1, task.AttemptNum)
|
||||||
|
|
||||||
|
require.NotNil(t, task.Body)
|
||||||
|
assert.Equal(t, fresh.Body, *task.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_IsRepeatable proves the requirement replay
|
||||||
|
// deliberately does not meet: firing the same captured event at a
|
||||||
|
// backend over and over must work, with no in-flight refusal, and each
|
||||||
|
// press must produce its own event and its own delivery.
|
||||||
|
func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
|
||||||
|
|
||||||
|
// Nothing between the presses marks the earlier deliveries
|
||||||
|
// finished, so every one of these is submitted while the last is
|
||||||
|
// still pending.
|
||||||
|
const presses = 5
|
||||||
|
|
||||||
|
for range presses {
|
||||||
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||||
|
w.Header().Get("Location"),
|
||||||
|
"a resubmit must not be refused while an earlier "+
|
||||||
|
"one is in flight",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, presses+1)
|
||||||
|
|
||||||
|
tasks := notif.Tasks()
|
||||||
|
require.Len(t, tasks, presses)
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, presses)
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
assert.Equal(t, tgt.ID, task.TargetID)
|
||||||
|
assert.NotEqual(
|
||||||
|
t, original.ID, task.EventID,
|
||||||
|
"each resubmit delivers its own new event",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, dup := seen[task.EventID]
|
||||||
|
assert.False(t, dup, "each resubmit creates its own event")
|
||||||
|
|
||||||
|
seen[task.EventID] = struct{}{}
|
||||||
|
|
||||||
|
require.Len(t, listDeliveries(t, webhookDB, task.EventID), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every copy names the same source event, so twenty presses stay
|
||||||
|
// traceable to the one captured request.
|
||||||
|
for _, e := range events {
|
||||||
|
if e.ID == original.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, e.ResubmittedFromID)
|
||||||
|
assert.Equal(t, original.ID, *e.ResubmittedFromID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_OversizeBodySurvivesIntact covers the
|
||||||
|
// non-inline case: a body above delivery.MaxInlineBodySize is not
|
||||||
|
// carried on the task at all, so it has to be copied into the new
|
||||||
|
// event row byte-identically for the engine to load it from there.
|
||||||
|
func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// Well over the inline limit, and not text: a multibyte rune, a
|
||||||
|
// NUL and a byte that is not valid UTF-8, so a copy that went
|
||||||
|
// through a re-encode or a truncation is visible in the compare.
|
||||||
|
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
||||||
|
|
||||||
|
stored := strings.Repeat("A", delivery.MaxInlineBodySize) +
|
||||||
|
"é\x00\xff" +
|
||||||
|
strings.Repeat("B", 4096) + sentinel
|
||||||
|
|
||||||
|
require.Greater(t, len(stored), delivery.MaxInlineBodySize)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
original := seedStoredEvent(t, dbMgr, wh.ID, stored)
|
||||||
|
|
||||||
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
||||||
|
|
||||||
|
assert.Len(t, fresh.Body, len(stored))
|
||||||
|
assert.Equal(
|
||||||
|
t, stored, fresh.Body,
|
||||||
|
"the stored body must be copied byte for byte",
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := notif.Tasks()
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
assert.Nil(
|
||||||
|
t, tasks[0].Body,
|
||||||
|
"a body over the inline limit is fetched from the new "+
|
||||||
|
"event row rather than carried on the task",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The engine's own read of the body, against the new event id:
|
||||||
|
// what it would send is what was stored.
|
||||||
|
var loaded database.Event
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Select("body").
|
||||||
|
First(&loaded, "id = ?", tasks[0].EventID).Error)
|
||||||
|
assert.Equal(t, stored, loaded.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated
|
||||||
|
// target is skipped exactly as the receiver skips it — not an error,
|
||||||
|
// and not a delivery the operator switched off.
|
||||||
|
func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
active := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
off := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`/off"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Model(&database.Target{}).
|
||||||
|
Where("id = ?", off.ID).
|
||||||
|
Update("active", false).Error)
|
||||||
|
|
||||||
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"skip":"one"}`)
|
||||||
|
|
||||||
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||||
|
w.Header().Get("Location"),
|
||||||
|
"an inactive target is skipped, not an error",
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := notif.Tasks()
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
assert.Equal(t, active.ID, tasks[0].TargetID)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
||||||
|
require.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a
|
||||||
|
// source with nothing to deliver to behaves as the receiver does: the
|
||||||
|
// event is stored, nothing is queued, and the operator is told so
|
||||||
|
// rather than being shown an error.
|
||||||
|
func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"no":"targets"}`)
|
||||||
|
|
||||||
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
||||||
|
w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, notif.Tasks())
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, listEvents(t, webhookDB), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the
|
||||||
|
// route cannot re-inject an event out of a webhook the session's user
|
||||||
|
// does not own, and reports the same 404 for an id that names nothing.
|
||||||
|
func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
notif *recordingNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
theirs := seedWebhookFor(t, db, otherTestUserID)
|
||||||
|
theirEvent := seedStoredEvent(t, dbMgr, theirs.ID, `{"not":"mine"}`)
|
||||||
|
|
||||||
|
mine := seedWebhook(t, db)
|
||||||
|
seedConfiguredTarget(
|
||||||
|
t, db, mine.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
|
||||||
|
|
||||||
|
// Their webhook, as its owner would address it.
|
||||||
|
w := postResubmit(t, h, sess, theirs.ID, theirEvent.ID)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
|
// Their event id, addressed through a webhook the user does own.
|
||||||
|
w = postResubmit(t, h, sess, mine.ID, theirEvent.ID)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
|
// An id that names no event at all.
|
||||||
|
w = postResubmit(t, h, sess, mine.ID, uuid.NewString())
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
|
// A malformed id never reaches the query.
|
||||||
|
w = postResubmit(t, h, sess, mine.ID, "not-a-uuid")
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
|
assert.Empty(
|
||||||
|
t, notif.Tasks(),
|
||||||
|
"a refused resubmit must queue nothing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_ShowsResubmitProvenance proves the event log
|
||||||
|
// reports the relationship in both directions, which is what keeps it
|
||||||
|
// readable once one captured event has been fired repeatedly.
|
||||||
|
func TestHandleSourceLogs_ShowsResubmitProvenance(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)
|
||||||
|
seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+resubmitTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
http.StatusSeeOther,
|
||||||
|
postResubmit(t, h, sess, wh.ID, original.ID).Code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, 3)
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body, "Resubmitted as 2 new events",
|
||||||
|
"the source event must show it has been resubmitted",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, "Resubmitted from event",
|
||||||
|
"a copy must show where it came from",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||||
|
"the log must offer the resubmit action per event",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,6 +27,17 @@ const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
|||||||
// per-delivery attempt ceiling to the handlers_test package.
|
// per-delivery attempt ceiling to the handlers_test package.
|
||||||
const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
||||||
|
|
||||||
|
// MaxTargetRetriesForTest exposes the target max_retries ceiling to
|
||||||
|
// the handlers_test package, so the tests assert against the constant
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
// DummyVerificationsForTest reports how many equivalent-cost
|
// DummyVerificationsForTest reports how many equivalent-cost
|
||||||
// verifications were charged for usernames that do not exist. It
|
// verifications were charged for usernames that do not exist. It
|
||||||
// lets a test prove the anti-enumeration path ran without timing
|
// lets a test prove the anti-enumeration path ran without timing
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The footer in base.html falls back to the literal "dev" when the
|
||||||
|
// template data carries no version, which is what every page rendered
|
||||||
|
// while nothing supplied one. The operator uses the footer to tell
|
||||||
|
// which build is live, so it has to carry the stamped value.
|
||||||
|
func TestFooterReportsStampedVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
g *globals.Globals
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &g)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
g.Version = "v9.9.9-test"
|
||||||
|
|
||||||
|
html := renderPage(t, h, sess, "login.html", map[string]any{
|
||||||
|
dataKeyError: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, html, "<span>v9.9.9-test</span>")
|
||||||
|
assert.NotContains(t, html, "<span>dev</span>")
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ type HandlersParams struct {
|
|||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Evictor delivery.WebhookEvictor
|
Evictor delivery.WebhookEvictor
|
||||||
|
SSRFGuard *delivery.Guard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -77,6 +78,11 @@ type Handlers struct {
|
|||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
|
|
||||||
|
// ssrf validates submitted target URLs. It is the same guard
|
||||||
|
// the delivery engine dials through, so a URL accepted here
|
||||||
|
// is one delivery will actually attempt.
|
||||||
|
ssrf *delivery.Guard
|
||||||
|
|
||||||
// dummyVerifications counts the equivalent-cost verifications
|
// dummyVerifications counts the equivalent-cost verifications
|
||||||
// charged for usernames that do not exist. It exists so a test
|
// charged for usernames that do not exist. It exists so a test
|
||||||
// can prove that path runs without measuring wall-clock time.
|
// can prove that path runs without measuring wall-clock time.
|
||||||
@@ -117,6 +123,7 @@ func New(
|
|||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.evictor = params.Evictor
|
s.evictor = params.Evictor
|
||||||
s.mtr = metrics.Default()
|
s.mtr = metrics.Default()
|
||||||
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
@@ -177,6 +184,7 @@ type UserInfo struct {
|
|||||||
type templateDataWrapper struct {
|
type templateDataWrapper struct {
|
||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
|
Version string
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,9 +235,16 @@ func (s *Handlers) renderTemplate(
|
|||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
|
// The footer in base.html renders .Version. Every page reaches it
|
||||||
|
// through here, so this is the one place that has to supply it;
|
||||||
|
// left unset, the footer falls back to its literal "dev" and the
|
||||||
|
// UI reports a build that is not the one running.
|
||||||
|
version := s.params.Globals.Version
|
||||||
|
|
||||||
if m, ok := data.(map[string]any); ok {
|
if m, ok := data.(map[string]any); ok {
|
||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
|
m["Version"] = version
|
||||||
s.executeTemplate(w, tmpl, m)
|
s.executeTemplate(w, tmpl, m)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -238,6 +253,7 @@ func (s *Handlers) renderTemplate(
|
|||||||
wrapper := templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
|
Version: version,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func newTestApp(
|
|||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
middleware.New,
|
middleware.New,
|
||||||
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
),
|
),
|
||||||
fx.Populate(targets...),
|
fx.Populate(targets...),
|
||||||
|
|||||||
@@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple"
|
|||||||
// storedFillBytes is the raw length of the client-chosen value in
|
// storedFillBytes is the raw length of the client-chosen value in
|
||||||
// those accounts' usernames. It is well past the 512-byte field
|
// those accounts' usernames. It is well past the 512-byte field
|
||||||
// budget, so the line is still truncated, but short enough that the
|
// budget, so the line is still truncated, but short enough that the
|
||||||
// session cookie a successful login writes stays inside
|
// whole username, markers and fill name included, stays within
|
||||||
// securecookie's 4 KB limit: the cookie is written BEFORE the
|
// database.MaxUsernameBytes.
|
||||||
// "user logged in" line, so an 8 KB username answers 500 and never
|
const storedFillBytes = 960
|
||||||
// reaches it.
|
|
||||||
const storedFillBytes = 1024
|
|
||||||
|
|
||||||
// storedFill builds a username fill of storedFillBytes raw bytes out
|
// storedFill builds a username fill of storedFillBytes raw bytes out
|
||||||
// of repetitions of ch, with both markers at its far end.
|
// of repetitions of ch, with both markers at its far end.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
@@ -73,6 +75,77 @@ func seedTarget(
|
|||||||
return tgt
|
return tgt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errInjectedDelete is the failure failDeleteOnTable reports
|
||||||
|
// from a delete statement.
|
||||||
|
var errInjectedDelete = errors.New("injected delete failure")
|
||||||
|
|
||||||
|
// seedEntrypoint inserts an entrypoint for a webhook.
|
||||||
|
func seedEntrypoint(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
webhookID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Path: "ep-" + webhookID,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRows counts the live (not soft-deleted) rows of a model
|
||||||
|
// matching column = value.
|
||||||
|
func countRows(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
model any,
|
||||||
|
column, value string,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.DB().Model(model).
|
||||||
|
Where(column+" = ?", value).
|
||||||
|
Count(&n).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// failDeleteOnTable makes every delete against the named table
|
||||||
|
// fail the way a database-level error does: the statement
|
||||||
|
// reports an error but leaves the surrounding transaction
|
||||||
|
// usable, so a caller that does not check it can go on to
|
||||||
|
// commit the statements that did succeed.
|
||||||
|
func failDeleteOnTable(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
table string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Callback().Delete().
|
||||||
|
Before("gorm:delete").
|
||||||
|
Register(
|
||||||
|
"test:fail_delete_"+table,
|
||||||
|
func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == table {
|
||||||
|
_ = tx.AddError(errInjectedDelete)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// archivePathFor returns the archive database path the
|
// archivePathFor returns the archive database path the
|
||||||
// delivery engine would use for a webhook: beside the webhook's
|
// delivery engine would use for a webhook: beside the webhook's
|
||||||
// event database in the data directory.
|
// event database in the data directory.
|
||||||
@@ -209,6 +282,159 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDelete_FailedDeleteKeepsEverything proves
|
||||||
|
// that a failing delete statement loses nothing: the
|
||||||
|
// configuration is rolled back whole, the event database
|
||||||
|
// survives, and the operator is told the deletion failed
|
||||||
|
// instead of being redirected as though it worked.
|
||||||
|
func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
mgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEntrypoint(t, db, wh.ID)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
||||||
|
|
||||||
|
eventDBPath := mgr.DBPath(wh.ID)
|
||||||
|
require.FileExists(t, eventDBPath)
|
||||||
|
|
||||||
|
// The entrypoint delete runs first and succeeds; the target
|
||||||
|
// delete then fails, which is what the whole transaction has
|
||||||
|
// to be rolled back over.
|
||||||
|
failDeleteOnTable(t, db, "targets")
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/source/"+wh.ID+"/delete",
|
||||||
|
cookies,
|
||||||
|
map[string]string{paramSourceID: wh.ID},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusInternalServerError, w.Code,
|
||||||
|
"a failed deletion must be reported, not redirected",
|
||||||
|
)
|
||||||
|
assert.Empty(
|
||||||
|
t, w.Header().Get("Location"),
|
||||||
|
"a failed deletion must not redirect to /sources",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1),
|
||||||
|
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
||||||
|
"the webhook must survive a failed deletion",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1),
|
||||||
|
countRows(
|
||||||
|
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
||||||
|
),
|
||||||
|
"the entrypoint delete must be rolled back",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(1),
|
||||||
|
countRows(
|
||||||
|
t, db, &database.Target{}, "webhook_id", wh.ID,
|
||||||
|
),
|
||||||
|
"the target must survive a failed deletion",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.FileExists(
|
||||||
|
t, eventDBPath,
|
||||||
|
"event history must not be destroyed when the "+
|
||||||
|
"configuration delete did not commit",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDelete_RemovesConfigAndEventDatabase is the
|
||||||
|
// positive control for the rollback above: an ordinary deletion
|
||||||
|
// still removes the webhook, its children and its event
|
||||||
|
// database.
|
||||||
|
func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
mgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEntrypoint(t, db, wh.ID)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
||||||
|
|
||||||
|
eventDBPath := mgr.DBPath(wh.ID)
|
||||||
|
require.FileExists(t, eventDBPath)
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/source/"+wh.ID+"/delete",
|
||||||
|
cookies,
|
||||||
|
map[string]string{paramSourceID: wh.ID},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(0),
|
||||||
|
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(0),
|
||||||
|
countRows(
|
||||||
|
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, int64(0),
|
||||||
|
countRows(
|
||||||
|
t, db, &database.Target{}, "webhook_id", wh.ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.NoFileExists(
|
||||||
|
t, eventDBPath,
|
||||||
|
"a successful deletion removes the event database",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
||||||
// proves that removing the last database target releases the
|
// proves that removing the last database target releases the
|
||||||
// archive writer.
|
// archive writer.
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The only two schemes a rendered entrypoint URL may carry,
|
||||||
|
// whatever the request claimed.
|
||||||
|
const (
|
||||||
|
schemeHTTPS = "https"
|
||||||
|
schemeHTTP = "http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entrypointURLPattern captures the entrypoint URL the source
|
||||||
|
// detail page renders, which is the operator-visible product of
|
||||||
|
// BaseURL. Asserting on the extracted string rather than on a
|
||||||
|
// substring of the page proves the raw header value cannot reach
|
||||||
|
// the scheme by any route.
|
||||||
|
var entrypointURLPattern = regexp.MustCompile(
|
||||||
|
`<code id="entrypoint-url-[^"]*"[^>]*>([^<]*)</code>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// baseURLFixture is one started app plus the webhook whose
|
||||||
|
// entrypoint URL the BaseURL cases read.
|
||||||
|
type baseURLFixture struct {
|
||||||
|
handlers *handlers.Handlers
|
||||||
|
session *session.Session
|
||||||
|
webhook string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBaseURLFixture starts the app and seeds a webhook with one
|
||||||
|
// entrypoint.
|
||||||
|
func newBaseURLFixture(t *testing.T) *baseURLFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
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)
|
||||||
|
seedEntrypoint(t, db, wh.ID)
|
||||||
|
|
||||||
|
return &baseURLFixture{
|
||||||
|
handlers: h,
|
||||||
|
session: sess,
|
||||||
|
webhook: wh.ID,
|
||||||
|
path: "ep-" + wh.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypointURL renders the source detail page for the fixture's
|
||||||
|
// webhook over a request the caller shapes, and returns the
|
||||||
|
// entrypoint URL as an operator would copy it.
|
||||||
|
func (f *baseURLFixture) entrypointURL(
|
||||||
|
t *testing.T,
|
||||||
|
host string,
|
||||||
|
shape func(*http.Request),
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet,
|
||||||
|
"/source/"+f.webhook,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
req.Host = host
|
||||||
|
|
||||||
|
shape(req)
|
||||||
|
|
||||||
|
for _, c := range authenticatedCookies(
|
||||||
|
t, f.session, deleteTestUserID, deleteTestUsername,
|
||||||
|
) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add(paramSourceID, f.webhook)
|
||||||
|
req = req.WithContext(
|
||||||
|
context.WithValue(
|
||||||
|
req.Context(), chi.RouteCtxKey, rctx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.handlers.HandleSourceDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
match := entrypointURLPattern.FindStringSubmatch(w.Body.String())
|
||||||
|
require.Len(
|
||||||
|
t, match, 2,
|
||||||
|
"the page must render exactly one entrypoint URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedProto returns a request shaper setting
|
||||||
|
// X-Forwarded-Proto, or leaving the request alone for "".
|
||||||
|
func forwardedProto(value string) func(*http.Request) {
|
||||||
|
return func(r *http.Request) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("X-Forwarded-Proto", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURLCase is one X-Forwarded-Proto spelling and the scheme
|
||||||
|
// the rendered entrypoint URL owes it.
|
||||||
|
type baseURLCase struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
scheme string
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURLCases enumerate the spellings a proxy really emits. The
|
||||||
|
// scheme is only ever http or https: the header value itself is
|
||||||
|
// never a scheme, however it is spelled.
|
||||||
|
func baseURLCases() []baseURLCase {
|
||||||
|
return []baseURLCase{
|
||||||
|
{
|
||||||
|
name: "lowercase",
|
||||||
|
header: schemeHTTPS,
|
||||||
|
scheme: schemeHTTPS,
|
||||||
|
why: "the ordinary spelling",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase",
|
||||||
|
header: "HTTPS",
|
||||||
|
scheme: schemeHTTPS,
|
||||||
|
why: "the token is case-insensitive; the scheme " +
|
||||||
|
"in a copyable URL is not",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with plaintext inner hop",
|
||||||
|
header: "https, http",
|
||||||
|
scheme: schemeHTTPS,
|
||||||
|
why: "a chained proxy appends its hop; the " +
|
||||||
|
"leftmost element faces the client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain of two TLS hops",
|
||||||
|
header: "https,https",
|
||||||
|
scheme: schemeHTTPS,
|
||||||
|
why: "appended chain with no space after the comma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing space",
|
||||||
|
header: "https ",
|
||||||
|
scheme: schemeHTTPS,
|
||||||
|
why: "whitespace is not part of the token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plaintext",
|
||||||
|
header: schemeHTTP,
|
||||||
|
scheme: schemeHTTP,
|
||||||
|
why: "the negative control: the proxy reports plaintext",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no header",
|
||||||
|
header: "",
|
||||||
|
scheme: schemeHTTP,
|
||||||
|
why: "a plaintext request asserting nothing is http",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "garbage token",
|
||||||
|
header: "javascript:alert(1)//",
|
||||||
|
scheme: schemeHTTP,
|
||||||
|
why: "anything that is not https is not TLS, and " +
|
||||||
|
"the token never becomes the scheme",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceDetailBaseURL_ForwardedProtoSpellings is the
|
||||||
|
// regression test for the entrypoint URL an operator pastes into
|
||||||
|
// the sending system: a header spelling that used to land in the
|
||||||
|
// scheme verbatim produced a URL no sender could deliver to.
|
||||||
|
func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "hooks.example.com"
|
||||||
|
|
||||||
|
fixture := newBaseURLFixture(t)
|
||||||
|
|
||||||
|
for _, tc := range baseURLCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
||||||
|
fixture.entrypointURL(
|
||||||
|
t, host, forwardedProto(tc.header),
|
||||||
|
),
|
||||||
|
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader pins the
|
||||||
|
// precedence the old code had backwards: it let any present
|
||||||
|
// header overwrite what the connection itself proved, so a
|
||||||
|
// direct-TLS request behind a proxy reporting http rendered an
|
||||||
|
// http URL.
|
||||||
|
func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "hooks.example.com"
|
||||||
|
|
||||||
|
fixture := newBaseURLFixture(t)
|
||||||
|
|
||||||
|
got := fixture.entrypointURL(t, host, func(r *http.Request) {
|
||||||
|
r.TLS = &tls.ConnectionState{}
|
||||||
|
r.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"https://"+host+"/webhook/"+fixture.path,
|
||||||
|
got,
|
||||||
|
"a connection this process terminated with TLS "+
|
||||||
|
"outranks a header claiming plaintext",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceDetailBaseURL_KeepsHostAuthority pins the host half
|
||||||
|
// of the URL: it is taken from the request unchanged, so the
|
||||||
|
// deployments that do not sit on port 443 still get a URL that
|
||||||
|
// works. Constraining the host would break exactly these.
|
||||||
|
func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fixture := newBaseURLFixture(t)
|
||||||
|
|
||||||
|
hosts := []string{
|
||||||
|
"hooks.example.com:8443",
|
||||||
|
"[2001:db8::1]:8443",
|
||||||
|
"internal-host",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"https://"+host+"/webhook/"+fixture.path,
|
||||||
|
fixture.entrypointURL(
|
||||||
|
t, host, forwardedProto("HTTPS"),
|
||||||
|
),
|
||||||
|
"the authority must survive verbatim, port and all",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deletedMarker is the suffix the event log appends to the name
|
||||||
|
// of a target that no longer exists.
|
||||||
|
const deletedMarker = " (deleted)"
|
||||||
|
|
||||||
|
// deleteTargetThroughHandler removes a target through the real
|
||||||
|
// deletion handler, so the test soft-deletes exactly the way the
|
||||||
|
// UI does rather than by writing the timestamp itself.
|
||||||
|
func deleteTargetThroughHandler(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID, targetID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
||||||
|
authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
),
|
||||||
|
map[string]string{
|
||||||
|
paramSourceID: webhookID,
|
||||||
|
paramTargetID: targetID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_NamesDeletedTarget proves a delivery
|
||||||
|
// produced by a since-deleted target still names it on the event
|
||||||
|
// log, marked as deleted.
|
||||||
|
//
|
||||||
|
// Deletes are soft and deliveries carry no foreign key to the
|
||||||
|
// target row, so the history outlives the target. Against a
|
||||||
|
// scoped lookup the delivery resolves to a zero view and the page
|
||||||
|
// renders ": delivered" with nothing saying what it was delivered
|
||||||
|
// to.
|
||||||
|
func TestHandleSourceLogs_NamesDeletedTarget(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)
|
||||||
|
|
||||||
|
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
// The control: the name is on the page while the target
|
||||||
|
// lives, and is not yet marked as deleted.
|
||||||
|
before := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
assert.Contains(t, before, tgt.Name)
|
||||||
|
assert.NotContains(t, before, tgt.Name+deletedMarker)
|
||||||
|
|
||||||
|
deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
after := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, after, tgt.Name+deletedMarker,
|
||||||
|
"a delivery from a deleted target must keep its name, "+
|
||||||
|
"marked as no longer existing",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, after, "delivered",
|
||||||
|
"the delivery history itself must survive the delete",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// a live target's is.
|
||||||
|
//
|
||||||
|
// The lookup behind the name reads soft-deleted rows, so it
|
||||||
|
// carries a full target row — credential blob included — into the
|
||||||
|
// place a zero value used to sit. The projection to TargetView is
|
||||||
|
// what keeps that blob away from the template, and it must hold
|
||||||
|
// for a deleted row too.
|
||||||
|
func TestHandleSourceLogs_MasksDeletedTargetConfig(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.TargetTypeSlack,
|
||||||
|
`{"webhookUrl":"`+slackWebhookURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.NotContains(t, body, slackSecretPath)
|
||||||
|
assert.NotContains(t, body, "T00000000")
|
||||||
|
assert.NotContains(t, body, "B00000000")
|
||||||
|
assert.NotContains(
|
||||||
|
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
||||||
|
)
|
||||||
|
assert.NotContains(t, body, "webhookUrl")
|
||||||
|
|
||||||
|
// The name is there; only the credential is not.
|
||||||
|
assert.Contains(t, body, tgt.Name+deletedMarker)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookListItem holds data for the webhook list view.
|
// WebhookListItem holds data for the webhook list view.
|
||||||
@@ -428,31 +428,28 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
host := r.Host
|
scheme := "http"
|
||||||
scheme := "https"
|
if reqtls.IsTLS(r) {
|
||||||
|
scheme = "https"
|
||||||
if r.TLS == nil {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
|
// The host is the client's Host header, unvalidated. It is
|
||||||
scheme = fwdProto
|
// inert only because source_detail.html renders BaseURL as
|
||||||
}
|
// text inside a <code> 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
|
// The template calls Webhook methods, which take pointer
|
||||||
// receivers; html/template cannot address a value stored in a map.
|
// receivers; html/template cannot address a value stored in a map.
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
// Entrypoints and targets are both projected to
|
// Targets are projected to a display-safe view: a
|
||||||
// display-safe views: an entrypoint carries the shared
|
// target's stored config blob holds a credential, and it
|
||||||
// secret its senders sign with and a target's stored
|
// must never reach a template.
|
||||||
// config blob holds a credential, and neither must ever
|
"Entrypoints": NewEntrypointViews(entrypoints),
|
||||||
// reach a template.
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
"Events": events,
|
||||||
"Targets": delivery.NewTargetViews(targets),
|
"BaseURL": baseURL,
|
||||||
"SignatureSchemes": signature.Schemes(),
|
|
||||||
"Events": events,
|
|
||||||
"BaseURL": scheme + "://" + host,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
@@ -625,43 +622,27 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
userID string,
|
userID string,
|
||||||
) {
|
) {
|
||||||
tx := h.db.DB().Begin()
|
// The configuration delete commits before the event database
|
||||||
if tx.Error != nil {
|
// is touched. No transaction spans the main database and the
|
||||||
h.log.Error(
|
// filesystem, so one side has to go first: committing the
|
||||||
"failed to begin transaction",
|
// configuration first means a later failure leaves an unused
|
||||||
"error", tx.Error,
|
// event database file on disk, while removing the event
|
||||||
)
|
// database first would mean a failed commit destroys the
|
||||||
http.Error(
|
// history of a webhook that still exists. A leftover file can
|
||||||
w, "Internal server error",
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
http.StatusInternalServerError,
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.Where(
|
|
||||||
"webhook_id = ?", webhook.ID,
|
|
||||||
).Delete(&database.Entrypoint{})
|
|
||||||
|
|
||||||
tx.Where(
|
|
||||||
"webhook_id = ?", webhook.ID,
|
|
||||||
).Delete(&database.Target{})
|
|
||||||
|
|
||||||
tx.Delete(&webhook)
|
|
||||||
|
|
||||||
err := tx.Commit().Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
h.serverError(w, "failed to delete webhook", err)
|
||||||
"failed to commit deletion", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.log.Info(
|
||||||
|
"webhook deleted",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
)
|
||||||
|
|
||||||
// Release the delivery engine's per-webhook archiving state
|
// Release the delivery engine's per-webhook archiving state
|
||||||
// so a deleted webhook's archive writer (and any handle open
|
// so a deleted webhook's archive writer (and any handle open
|
||||||
// within its debounce window) does not linger for the
|
// within its debounce window) does not linger for the
|
||||||
@@ -671,22 +652,63 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
|
|
||||||
err = h.dbMgr.DeleteDB(webhook.ID)
|
err = h.dbMgr.DeleteDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
// The configuration is committed, so the webhook is gone,
|
||||||
"failed to delete webhook event database",
|
// but its event database file is still on disk with
|
||||||
"webhook_id", webhook.ID,
|
// nothing referencing it. Report the failure rather than
|
||||||
"error", err,
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
h.log.Info(
|
|
||||||
"webhook deleted",
|
|
||||||
"webhook_id", webhook.ID,
|
|
||||||
"user_id", userID,
|
|
||||||
)
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
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
|
// evictArchiveWriter asks the delivery engine to drop its
|
||||||
// cached archive writer for a webhook, closing the archive file
|
// cached archive writer for a webhook, closing the archive file
|
||||||
// handle.
|
// handle.
|
||||||
@@ -821,25 +843,31 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
totalPages++
|
totalPages++
|
||||||
}
|
}
|
||||||
|
|
||||||
// The banner a replay POST redirected back with. The
|
// The banner a replay or resubmit POST redirected back
|
||||||
// message comes from a fixed set keyed by the outcome
|
// with. The message comes from a fixed set keyed by the
|
||||||
// code, never from the query string itself.
|
// outcome code, never from the query string itself.
|
||||||
replayMsg, replayOK := replayOutcome(
|
replayMsg, replayOK := replayOutcome(
|
||||||
r.URL.Query().Get(replayOutcomeParam),
|
r.URL.Query().Get(replayOutcomeParam),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resubmitMsg, resubmitOK := resubmitOutcome(
|
||||||
|
r.URL.Query().Get(resubmitOutcomeParam),
|
||||||
|
)
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
"ReplayMessage": replayMsg,
|
"ReplayMessage": replayMsg,
|
||||||
"ReplayQueued": replayOK,
|
"ReplayQueued": replayOK,
|
||||||
"Page": page,
|
"ResubmitMessage": resubmitMsg,
|
||||||
"TotalPages": totalPages,
|
"ResubmitQueued": resubmitOK,
|
||||||
"TotalEvents": total,
|
"Page": page,
|
||||||
"HasPrev": page > 1,
|
"TotalPages": totalPages,
|
||||||
"HasNext": page < totalPages,
|
"TotalEvents": total,
|
||||||
"PrevPage": page - 1,
|
"HasPrev": page > 1,
|
||||||
"NextPage": page + 1,
|
"HasNext": page < totalPages,
|
||||||
|
"PrevPage": page - 1,
|
||||||
|
"NextPage": page + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_logs.html", data)
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
@@ -854,11 +882,16 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
//
|
//
|
||||||
// The load is Unscoped because deleting a target only soft
|
// The load is Unscoped because deleting a target only soft
|
||||||
// deletes the row while its deliveries survive in the
|
// deletes the row while its deliveries survive in the
|
||||||
// per-webhook database: a scoped load leaves those deliveries
|
// per-webhook database. Both halves of the map need those rows:
|
||||||
// with a zero redactor, which renders their response bodies
|
// a scoped load leaves an old delivery with a zero redactor,
|
||||||
// unredacted. Only the redactor half of the map is built from
|
// which renders its response bodies unredacted, and with a zero
|
||||||
// deleted rows. The view half, which is what the page lists,
|
// view, which renders its target as a blank name.
|
||||||
// stays scoped.
|
//
|
||||||
|
// 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(
|
func (h *Handlers) loadTargetMap(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) (map[string]eventLogTarget, error) {
|
) (map[string]eventLogTarget, error) {
|
||||||
@@ -874,21 +907,18 @@ func (h *Handlers) loadTargetMap(
|
|||||||
targetMap := make(
|
targetMap := make(
|
||||||
map[string]eventLogTarget, len(targets),
|
map[string]eventLogTarget, len(targets),
|
||||||
)
|
)
|
||||||
live := make([]database.Target, 0, len(targets))
|
|
||||||
|
|
||||||
for i := range targets {
|
for i := range targets {
|
||||||
targetMap[targets[i].ID] = eventLogTarget{
|
targetMap[targets[i].ID] = eventLogTarget{
|
||||||
Redactor: delivery.NewRedactor(&targets[i]),
|
Redactor: delivery.NewRedactor(&targets[i]),
|
||||||
}
|
}
|
||||||
|
|
||||||
if !targets[i].DeletedAt.Valid {
|
|
||||||
live = append(live, targets[i])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The views come from NewTargetViews rather than being
|
// The views come from NewTargetViews rather than being
|
||||||
// rebuilt here, so the masking rules stay in one place.
|
// rebuilt here, so the masking rules stay in one place and a
|
||||||
for _, v := range delivery.NewTargetViews(live) {
|
// 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 := targetMap[v.ID]
|
||||||
entry.View = v
|
entry.View = v
|
||||||
targetMap[v.ID] = entry
|
targetMap[v.ID] = entry
|
||||||
@@ -899,16 +929,7 @@ func (h *Handlers) loadTargetMap(
|
|||||||
|
|
||||||
// parsePage extracts a page number from the query string.
|
// parsePage extracts a page number from the query string.
|
||||||
func (h *Handlers) parsePage(r *http.Request) int {
|
func (h *Handlers) parsePage(r *http.Request) int {
|
||||||
page := 1
|
return pageOrFirst(r.URL.Query().Get("page"))
|
||||||
|
|
||||||
if p := r.URL.Query().Get("page"); p != "" {
|
|
||||||
v, err := strconv.Atoi(p)
|
|
||||||
if err == nil && v > 0 {
|
|
||||||
page = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return page
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEventsWithDeliveries loads paginated events and their
|
// loadEventsWithDeliveries loads paginated events and their
|
||||||
@@ -925,12 +946,10 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
) ([]EventLogView, int64, bool) {
|
) ([]EventLogView, int64, bool) {
|
||||||
var totalEvents int64
|
|
||||||
|
|
||||||
var result []EventLogView
|
var result []EventLogView
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
return result, totalEvents, true
|
return result, 0, true
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
@@ -942,29 +961,20 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB.Model(&database.Event{}).Where(
|
rows, totalEvents := loadEventLogRows(
|
||||||
"webhook_id = ?", webhook.ID,
|
webhookDB, webhook.ID, page,
|
||||||
).Count(&totalEvents)
|
)
|
||||||
|
|
||||||
offset := (page - 1) * paginationPerPage
|
|
||||||
|
|
||||||
var rows []eventLogRow
|
|
||||||
|
|
||||||
webhookDB.Model(&database.Event{}).Select(
|
|
||||||
eventLogColumns, maxRenderedBodyBytes,
|
|
||||||
).Where(
|
|
||||||
"webhook_id = ?", webhook.ID,
|
|
||||||
).Order("created_at DESC").Offset(offset).Limit(
|
|
||||||
paginationPerPage,
|
|
||||||
).Find(&rows)
|
|
||||||
|
|
||||||
result = make([]EventLogView, len(rows))
|
result = make([]EventLogView, len(rows))
|
||||||
eventDeliveries := make([][]database.Delivery, len(rows))
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
|
|
||||||
var deliveryIDs []string
|
var deliveryIDs []string
|
||||||
|
|
||||||
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
result[i] = rows[i].view()
|
result[i] = rows[i].view()
|
||||||
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
"event_id = ?", rows[i].ID,
|
"event_id = ?", rows[i].ID,
|
||||||
@@ -988,15 +998,86 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
return nil, 0, false
|
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 {
|
for i := range rows {
|
||||||
result[i].Deliveries = newDeliveryViews(
|
result[i].Deliveries = newDeliveryViews(
|
||||||
eventDeliveries[i], targetMap, attempts,
|
eventDeliveries[i], targetMap, attempts,
|
||||||
)
|
)
|
||||||
|
result[i].ResubmitCount = resubmits[rows[i].ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, totalEvents, true
|
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
|
// deliveryIDChunkSize bounds how many delivery IDs go into one
|
||||||
// IN clause. SQLite refuses a statement carrying more than
|
// IN clause. SQLite refuses a statement carrying more than
|
||||||
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
|
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
|
||||||
@@ -1181,145 +1262,6 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEntrypointSecret sets, rotates or removes the shared secret
|
|
||||||
// an entrypoint verifies inbound requests with.
|
|
||||||
//
|
|
||||||
// Setting and rotating are the same operation: the form always takes
|
|
||||||
// the secret afresh and the stored value is never sent to the browser
|
|
||||||
// to be edited, so there is no path by which the page can display a
|
|
||||||
// credential it holds. Rotation is therefore "submit the new secret",
|
|
||||||
// and the operator already has that value — both supported senders
|
|
||||||
// require them to enter the same string on the sender's side, so
|
|
||||||
// there is no generated value for webhooker to reveal once.
|
|
||||||
func (h *Handlers) HandleEntrypointSecret() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
webhook, ok := h.ownedWebhook(w, r)
|
|
||||||
if !ok {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var entrypoint database.Entrypoint
|
|
||||||
|
|
||||||
err = h.db.DB().Where(
|
|
||||||
"id = ? AND webhook_id = ?",
|
|
||||||
chi.URLParam(r, "entrypointID"), webhook.ID,
|
|
||||||
).First(&entrypoint).Error
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.applyEntrypointSecret(w, r, &entrypoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyEntrypointSecret validates the submitted scheme and secret and
|
|
||||||
// stores them.
|
|
||||||
//
|
|
||||||
// A scheme this build does not support is a 400, never a stored value
|
|
||||||
// the receiver would later have to interpret: the receiver fails such
|
|
||||||
// a row closed, so letting one be created would take the entrypoint
|
|
||||||
// offline through a form that reported success.
|
|
||||||
func (h *Handlers) applyEntrypointSecret(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
entrypoint *database.Entrypoint,
|
|
||||||
) {
|
|
||||||
// PostFormValue, not FormValue: a credential must come from the
|
|
||||||
// body. FormValue falls back to the query string, and the request
|
|
||||||
// line — unlike the body — is what logs, proxies, Referer headers
|
|
||||||
// and error trackers record.
|
|
||||||
scheme := database.SignatureScheme(
|
|
||||||
r.PostFormValue("signature_scheme"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Surrounding whitespace is stripped, because a secret pasted from
|
|
||||||
// a password manager routinely carries some and the resulting
|
|
||||||
// mismatch is undiagnosable from the sender's side. A secret whose
|
|
||||||
// own first or last character is a space cannot be stored; the
|
|
||||||
// README says so.
|
|
||||||
secret := strings.TrimSpace(r.PostFormValue("secret"))
|
|
||||||
|
|
||||||
if !signature.Supported(scheme) {
|
|
||||||
http.Error(
|
|
||||||
w, "Invalid signature scheme",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if scheme == database.SignatureSchemeNone {
|
|
||||||
// Turning verification off drops the secret with it: a stored
|
|
||||||
// credential nothing reads is one more copy to leak, and
|
|
||||||
// Verify refuses that pairing in any case.
|
|
||||||
secret = ""
|
|
||||||
} else if secret == "" {
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
"A shared secret is required for this signature scheme.",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.storeEntrypointSecret(w, r, entrypoint, scheme, secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeEntrypointSecret writes a validated scheme and secret to an
|
|
||||||
// entrypoint and returns the operator to the webhook page.
|
|
||||||
func (h *Handlers) storeEntrypointSecret(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
entrypoint *database.Entrypoint,
|
|
||||||
scheme database.SignatureScheme,
|
|
||||||
secret string,
|
|
||||||
) {
|
|
||||||
// Updates with a map rather than a struct: a struct update skips
|
|
||||||
// zero values, and the empty pair is exactly what has to be
|
|
||||||
// written when verification is being turned off.
|
|
||||||
err := h.db.DB().Model(entrypoint).Updates(map[string]any{
|
|
||||||
"signature_scheme": scheme,
|
|
||||||
"signature_secret": secret,
|
|
||||||
}).Error
|
|
||||||
if err != nil {
|
|
||||||
// The error is logged by serverError; GORM's error text
|
|
||||||
// carries the statement, not the bound values, so the secret
|
|
||||||
// does not travel with it.
|
|
||||||
h.serverError(
|
|
||||||
w, "failed to update entrypoint signature", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.log.Info(
|
|
||||||
"entrypoint signature configuration updated",
|
|
||||||
"entrypoint_id", entrypoint.ID,
|
|
||||||
"webhook_id", entrypoint.WebhookID,
|
|
||||||
"scheme", string(scheme),
|
|
||||||
)
|
|
||||||
|
|
||||||
http.Redirect(
|
|
||||||
w, r,
|
|
||||||
"/source/"+entrypoint.WebhookID,
|
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleTargetCreate handles adding a new target to a webhook.
|
// HandleTargetCreate handles adding a new target to a webhook.
|
||||||
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1377,7 +1319,6 @@ func (h *Handlers) processTargetCreate(
|
|||||||
// Referer headers and error trackers record.
|
// Referer headers and error trackers record.
|
||||||
name := r.PostFormValue("name")
|
name := r.PostFormValue("name")
|
||||||
targetType := database.TargetType(r.PostFormValue("type"))
|
targetType := database.TargetType(r.PostFormValue("type"))
|
||||||
maxRetriesStr := r.PostFormValue("max_retries")
|
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -1403,7 +1344,14 @@ func (h *Handlers) processTargetCreate(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
maxRetries := parseNonNegativeInt(maxRetriesStr)
|
// 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{
|
target := &database.Target{
|
||||||
WebhookID: webhook.ID,
|
WebhookID: webhook.ID,
|
||||||
@@ -1439,19 +1387,22 @@ func isValidTargetType(tt database.TargetType) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNonNegativeInt parses s as a non-negative integer,
|
// pageOrFirst parses a paginated page number, answering 1 for
|
||||||
// returning 0 if s is empty or invalid.
|
// anything empty, unparseable or out of range.
|
||||||
func parseNonNegativeInt(s string) int {
|
//
|
||||||
if s == "" {
|
// Falling back rather than rejecting is correct here and only here:
|
||||||
return 0
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := strconv.Atoi(s)
|
return v
|
||||||
if err == nil && v >= 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetFormInput carries the raw form values describing a target's
|
// targetFormInput carries the raw form values describing a target's
|
||||||
@@ -1607,7 +1558,7 @@ func (h *Handlers) validateTargetURL(
|
|||||||
return errMissingURL
|
return errMissingURL
|
||||||
}
|
}
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := h.ssrf.ValidateTargetURL(
|
||||||
r.Context(), targetURL,
|
r.Context(), targetURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -133,20 +133,28 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target.Name = name
|
|
||||||
target.Config = configJSON
|
|
||||||
|
|
||||||
// Retries are offered only by the forms for target types that
|
// Retries are offered only by the forms for target types that
|
||||||
// retry, so an absent field means "this form does not edit
|
// retry, so an absent field means "this form does not edit
|
||||||
// retries" rather than "set them to zero". Reading it
|
// retries" rather than "set them to zero". Reading it
|
||||||
// unconditionally would silently disable retries on any target
|
// unconditionally would silently disable retries on any target
|
||||||
// saved from a form that does not render the input.
|
// 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") {
|
if r.PostForm.Has("max_retries") {
|
||||||
target.MaxRetries = parseNonNegativeInt(
|
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
|
||||||
r.PostFormValue("max_retries"),
|
if !ok {
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.MaxRetries = retries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target.Name = name
|
||||||
|
target.Config = configJSON
|
||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
err = h.db.DB().Save(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update target", err)
|
h.serverError(w, "failed to update target", err)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxTargetRetries bounds a target's max_retries.
|
||||||
|
//
|
||||||
|
// Both target forms already declare max="20" on the input, so this
|
||||||
|
// enforces server-side what the UI has always advertised rather than
|
||||||
|
// introducing a new limit.
|
||||||
|
//
|
||||||
|
// The number is not cosmetic. Every attempt writes a delivery_results
|
||||||
|
// row that the event log then loads and renders, and the engine backs
|
||||||
|
// off by 2^(n-1) seconds, so attempt 20 is already about six days
|
||||||
|
// after the first. A value beyond this buys no additional durability
|
||||||
|
// and only costs rows.
|
||||||
|
const maxTargetRetries = 20
|
||||||
|
|
||||||
|
// Errors returned when a max_retries form value cannot be turned into
|
||||||
|
// a retry count.
|
||||||
|
var (
|
||||||
|
// errRetriesInvalid signals a max_retries form value that is not
|
||||||
|
// a non-negative whole number.
|
||||||
|
errRetriesInvalid = errors.New(
|
||||||
|
"retries must be a whole number of attempts",
|
||||||
|
)
|
||||||
|
|
||||||
|
// errRetriesTooLarge signals a max_retries form value that is a
|
||||||
|
// whole number but above maxTargetRetries. It is distinguished
|
||||||
|
// from errRetriesInvalid so the message can name the ceiling
|
||||||
|
// instead of implying the input was not a number.
|
||||||
|
errRetriesTooLarge = errors.New("retries out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseMaxRetries interprets a max_retries form value.
|
||||||
|
//
|
||||||
|
// An ABSENT value — the field empty or not submitted — yields
|
||||||
|
// fallback, which lets the create path apply its default and the edit
|
||||||
|
// path leave the stored value alone. A value that is SET BUT INVALID
|
||||||
|
// is an error: unparseable, negative, or above maxTargetRetries.
|
||||||
|
//
|
||||||
|
// The distinction is the whole point of this function. max_retries=0
|
||||||
|
// means fire-and-forget, so returning 0 for input the operator typed
|
||||||
|
// but that did not parse silently disables retries on a
|
||||||
|
// store-and-forward proxy — and on the edit path it destroys a
|
||||||
|
// working retry configuration over a typo. A default answers a
|
||||||
|
// question that was not asked; it never answers one that was asked
|
||||||
|
// badly.
|
||||||
|
//
|
||||||
|
// A target stored with a count above the ceiling before this
|
||||||
|
// validation existed keeps rendering and keeps delivering — nothing
|
||||||
|
// clamps the row. Re-saving it from the edit form does have to bring
|
||||||
|
// it into range, because the form submits the pre-filled value back
|
||||||
|
// and accepting it would be the ceiling not applying to the edit
|
||||||
|
// path. The 400 names the ceiling, so the fix is one field.
|
||||||
|
func parseMaxRetries(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, errRetriesInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if v > maxTargetRetries {
|
||||||
|
return 0, errRetriesTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retriesErrorMessage returns the message the create and edit forms
|
||||||
|
// show for a rejected max_retries value. Any error other than
|
||||||
|
// errRetriesTooLarge falls back to the generic wording, so an
|
||||||
|
// unrecognised parse failure still produces a sensible 400.
|
||||||
|
func retriesErrorMessage(err error) string {
|
||||||
|
if errors.Is(err, errRetriesTooLarge) {
|
||||||
|
return errRetriesTooLarge.Error() +
|
||||||
|
": at most " + strconv.Itoa(maxTargetRetries) +
|
||||||
|
" retries"
|
||||||
|
}
|
||||||
|
|
||||||
|
return errRetriesInvalid.Error() +
|
||||||
|
", or 0 for fire-and-forget"
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetMaxRetries reads and validates max_retries from a target form
|
||||||
|
// submission, answering the request with a 400 and reporting false
|
||||||
|
// when the value is set but invalid.
|
||||||
|
//
|
||||||
|
// Both the create and the edit path go through here, so the two
|
||||||
|
// cannot come to disagree about what a valid retry count is. The
|
||||||
|
// wording matches the timeout control on the same submission.
|
||||||
|
func targetMaxRetries(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
fallback int,
|
||||||
|
) (int, bool) {
|
||||||
|
retries, err := parseMaxRetries(
|
||||||
|
r.PostFormValue("max_retries"), fallback,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
"Invalid max retries: "+retriesErrorMessage(err),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return retries, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retriesTargetURL is the destination the retry-validation targets
|
||||||
|
// point at. It is a literal public address rather than a hostname so
|
||||||
|
// the SSRF check resolves nothing and a sandbox without DNS cannot
|
||||||
|
// make these cases pass or fail for the wrong reason.
|
||||||
|
const retriesTargetURL = "https://93.184.216.34/hooks/retries"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// wayAboveCeiling is the typo'd-extra-zero case from the report.
|
||||||
|
wayAboveCeiling = "999999999"
|
||||||
|
|
||||||
|
// notANumber is the plainest garbage an operator can type, and
|
||||||
|
// the value the report submitted on the edit form.
|
||||||
|
notANumber = "abc"
|
||||||
|
|
||||||
|
// workingRetries is the retry count a seeded target is already
|
||||||
|
// delivering with, which a rejected submission must not disturb.
|
||||||
|
workingRetries = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// aboveCeiling is the smallest rejected whole number.
|
||||||
|
func aboveCeiling() string {
|
||||||
|
return strconv.Itoa(handlers.MaxTargetRetriesForTest + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// overCeilingRetries is whole-number input past the limit, which is
|
||||||
|
// rejected with the limit named.
|
||||||
|
func overCeilingRetries() []string {
|
||||||
|
return []string{aboveCeiling(), wayAboveCeiling}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unparseableRetries is input an operator can type into the field
|
||||||
|
// that is not a retry count. Each must be REJECTED: silently reading
|
||||||
|
// any of them as 0 turns a store-and-forward proxy into
|
||||||
|
// fire-and-forget without saying so.
|
||||||
|
//
|
||||||
|
// The twenty-digit case is here because it parses as digits but
|
||||||
|
// overflows int, which is the one failure the field's own min/max
|
||||||
|
// attributes cannot describe.
|
||||||
|
func unparseableRetries() []string {
|
||||||
|
return []string{
|
||||||
|
notANumber,
|
||||||
|
"2.7",
|
||||||
|
"-5",
|
||||||
|
"12345678901234567890",
|
||||||
|
"1e3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRetriesForm is a complete, otherwise-valid HTTP target
|
||||||
|
// creation, so the only thing any case below varies is max_retries.
|
||||||
|
func createRetriesForm(retries string) url.Values {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "retries-target")
|
||||||
|
form.Set("type", string(database.TargetTypeHTTP))
|
||||||
|
form.Set("url", retriesTargetURL)
|
||||||
|
|
||||||
|
if retries != absentField {
|
||||||
|
form.Set("max_retries", retries)
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// absentField marks a field the form does not submit at all, which is
|
||||||
|
// the case that legitimately takes a default and must stay distinct
|
||||||
|
// from a field submitted with garbage in it.
|
||||||
|
const absentField = "\x00absent"
|
||||||
|
|
||||||
|
// absentRetries is every way of saying "the operator did not set
|
||||||
|
// this", each of which takes the default rather than a 400. Blank and
|
||||||
|
// whitespace-only count as absent here because they do in the timeout
|
||||||
|
// and retention controls on the same forms; a rule the fields do not
|
||||||
|
// share would be its own surprise.
|
||||||
|
func absentRetries() []string {
|
||||||
|
return []string{absentField, "", " "}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWithRetries posts the target create form for a fresh webhook
|
||||||
|
// and returns the webhook and the response.
|
||||||
|
func createWithRetries(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
retries string,
|
||||||
|
) (database.Webhook, int, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/source/"+webhook.ID+"/targets",
|
||||||
|
createRetriesForm(retries),
|
||||||
|
)
|
||||||
|
|
||||||
|
return webhook, w.Code, w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_RetriesAboveCeilingRejected proves the create form
|
||||||
|
// enforces a ceiling at all, and that the 400 names it — a rejection
|
||||||
|
// that does not say what the limit is leaves the operator guessing.
|
||||||
|
func TestTargetCreate_RetriesAboveCeilingRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
||||||
|
|
||||||
|
for _, retries := range overCeilingRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code,
|
||||||
|
"max_retries=%s should be rejected", retries)
|
||||||
|
assert.Contains(t, body, ceiling,
|
||||||
|
"the rejection for %s should name the ceiling",
|
||||||
|
retries)
|
||||||
|
assert.Empty(t,
|
||||||
|
targetsForWebhook(t, env.db, webhook.ID),
|
||||||
|
"no target should be created for %s", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_UnparseableRetriesRejected is the core of the
|
||||||
|
// defect: each of these was accepted with HTTP 200 and stored as 0.
|
||||||
|
func TestTargetCreate_UnparseableRetriesRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range unparseableRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code,
|
||||||
|
"max_retries=%q should be rejected, not coerced",
|
||||||
|
retries)
|
||||||
|
assert.Contains(t, body, "whole number",
|
||||||
|
"the rejection for %q should say why", retries)
|
||||||
|
assert.Empty(t,
|
||||||
|
targetsForWebhook(t, env.db, webhook.ID),
|
||||||
|
"no target should be created for %q", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_ValidRetriesStored covers the accepting half,
|
||||||
|
// including the ceiling itself: a bound that rejects its own limit
|
||||||
|
// would make the advertised maximum unreachable.
|
||||||
|
func TestTargetCreate_ValidRetriesStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, want := range []int{0, 3, handlers.MaxTargetRetriesForTest} {
|
||||||
|
webhook, code, body := createWithRetries(
|
||||||
|
t, env, strconv.Itoa(want),
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, want, targets[0].MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_AbsentRetriesTakesDefault keeps the two cases
|
||||||
|
// distinct. An omitted field is not an operator asking for something
|
||||||
|
// invalid, so it still gets the fire-and-forget default rather than a
|
||||||
|
// 400 — otherwise the fix above would make the form unusable.
|
||||||
|
func TestTargetCreate_AbsentRetriesTakesDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range absentRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, 0, targets[0].MaxRetries,
|
||||||
|
"an absent max_retries should take the default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRetriesTarget creates an HTTP target already delivering with
|
||||||
|
// workingRetries retries, through the real create handler.
|
||||||
|
func seedRetriesTarget(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
) (database.Webhook, database.Target) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook, code, body := createWithRetries(
|
||||||
|
t, env, strconv.Itoa(workingRetries),
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
require.Equal(t, workingRetries, targets[0].MaxRetries)
|
||||||
|
|
||||||
|
return webhook, targets[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// editRetriesForm is a complete edit submission that changes the
|
||||||
|
// target's name as well, so a rejected submission can be shown to
|
||||||
|
// have written nothing at all rather than merely to have left
|
||||||
|
// max_retries alone.
|
||||||
|
func editRetriesForm(retries string) url.Values {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "renamed-by-edit")
|
||||||
|
form.Set("url", retriesTargetURL)
|
||||||
|
|
||||||
|
if retries != absentField {
|
||||||
|
form.Set("max_retries", retries)
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEditRejectedAndUnchanged submits an edit expected to fail and
|
||||||
|
// checks both halves of the requirement: the 400 explains itself, and
|
||||||
|
// the target it was submitted against is untouched.
|
||||||
|
func assertEditRejectedAndUnchanged(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
retries, wantReason string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID, editRetriesForm(retries),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code,
|
||||||
|
"max_retries=%q should be rejected on edit", retries)
|
||||||
|
assert.Contains(t, w.Body.String(), wantReason,
|
||||||
|
"the rejection for %q should say why", retries)
|
||||||
|
|
||||||
|
stored := storedTarget(t, env, target.ID)
|
||||||
|
assert.Equal(t, workingRetries, stored.MaxRetries,
|
||||||
|
"a rejected edit must not destroy the working retry "+
|
||||||
|
"count with %q", retries)
|
||||||
|
assert.Equal(t, "retries-target", stored.Name,
|
||||||
|
"a rejected edit must write nothing at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_UnparseableRetriesRejected is the damaging half of
|
||||||
|
// the defect. A target delivering with two retries, re-saved with a
|
||||||
|
// typo in the field, returned 200 and was left with retries disabled
|
||||||
|
// and nothing said.
|
||||||
|
func TestTargetEdit_UnparseableRetriesRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range unparseableRetries() {
|
||||||
|
assertEditRejectedAndUnchanged(
|
||||||
|
t, env, retries, "whole number",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_RetriesAboveCeilingRejected proves the ceiling
|
||||||
|
// applies to the edit path too, naming itself, so the two paths
|
||||||
|
// cannot disagree about what is storable.
|
||||||
|
func TestTargetEdit_RetriesAboveCeilingRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
||||||
|
|
||||||
|
for _, retries := range overCeilingRetries() {
|
||||||
|
assertEditRejectedAndUnchanged(t, env, retries, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_ValidRetriesStored covers the accepting half of the
|
||||||
|
// edit path, so the ceiling cannot be enforced by simply refusing
|
||||||
|
// every submission.
|
||||||
|
func TestTargetEdit_ValidRetriesStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, want := range []int{0, 9, handlers.MaxTargetRetriesForTest} {
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(strconv.Itoa(want)),
|
||||||
|
)
|
||||||
|
require.Equal(t,
|
||||||
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, want,
|
||||||
|
storedTarget(t, env, target.ID).MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_AbsentRetriesLeavesStoredValue is the edit path's
|
||||||
|
// absent-versus-invalid case. Retries are only offered by the forms
|
||||||
|
// for types that retry, so a submission without the field must leave
|
||||||
|
// the stored count alone rather than be rejected or zeroed.
|
||||||
|
func TestTargetEdit_AbsentRetriesLeavesStoredValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range absentRetries() {
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(retries),
|
||||||
|
)
|
||||||
|
require.Equal(t,
|
||||||
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, workingRetries,
|
||||||
|
storedTarget(t, env, target.ID).MaxRetries,
|
||||||
|
"an absent max_retries must leave the stored "+
|
||||||
|
"count alone (%q)", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRetries_CreateAndEditAgreeOnEveryCase proves the two
|
||||||
|
// paths cannot disagree, which is what let the create form and the
|
||||||
|
// edit form drift apart in the first place. Every input is submitted
|
||||||
|
// to both and the accept/reject verdicts are compared.
|
||||||
|
func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
accepted := []string{
|
||||||
|
"0", "1",
|
||||||
|
strconv.Itoa(handlers.MaxTargetRetriesForTest),
|
||||||
|
}
|
||||||
|
overCeiling := overCeilingRetries()
|
||||||
|
unparseable := unparseableRetries()
|
||||||
|
|
||||||
|
cases := make(
|
||||||
|
[]string, 0,
|
||||||
|
len(accepted)+len(overCeiling)+len(unparseable),
|
||||||
|
)
|
||||||
|
cases = append(cases, accepted...)
|
||||||
|
cases = append(cases, overCeiling...)
|
||||||
|
cases = append(cases, unparseable...)
|
||||||
|
|
||||||
|
for _, retries := range cases {
|
||||||
|
_, createCode, _ := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
editCode := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(retries),
|
||||||
|
).Code
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
createCode == http.StatusBadRequest,
|
||||||
|
editCode == http.StatusBadRequest,
|
||||||
|
"create and edit must agree on max_retries=%q "+
|
||||||
|
"(create %d, edit %d)",
|
||||||
|
retries, createCode, editCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
||||||
|
// non-numeric form value legitimately falls back. A page number says
|
||||||
|
// where to send the browser after an action that has already
|
||||||
|
// happened, so it is not configuration and rejecting it would report
|
||||||
|
// a failure that did not occur.
|
||||||
|
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
||||||
|
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
||||||
|
"%q should fall back to the first page", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
||||||
|
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Template data keys the page templates read. The handlers package has
|
// Template data keys the page templates read. The handlers package has
|
||||||
@@ -269,16 +268,15 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
|
|
||||||
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
|
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
|
||||||
dataKeyWebhook: webhook,
|
dataKeyWebhook: webhook,
|
||||||
// The handler passes projected views, never raw rows — an
|
// The handler passes projected views, never raw rows — a
|
||||||
// entrypoint carries its shared secret and a target its
|
// target carries its stored credential — so the test data
|
||||||
// stored credential — so the test data has that same shape.
|
// has that same shape.
|
||||||
"Entrypoints": handlers.NewEntrypointViews(
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
"SignatureSchemes": signature.Schemes(),
|
"Events": []database.Event{},
|
||||||
"Events": []database.Event{},
|
"BaseURL": "https://hooks.example.com",
|
||||||
"BaseURL": "https://hooks.example.com",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
@@ -302,3 +300,80 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
"the page must render to completion, not abort partway",
|
"the page must render to completion, not abort partway",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxRetriesHelp is the wording both target forms must carry. The
|
||||||
|
// delivery core makes max_retries attempts in total, not that many
|
||||||
|
// retries on top of a first try (a fresh delivery starts at attempt 1
|
||||||
|
// and target_http gives up once the attempt number reaches
|
||||||
|
// max_retries), and 0 is special-cased to a single fire-and-forget
|
||||||
|
// attempt with no circuit breaker.
|
||||||
|
const maxRetriesHelp = "This is the total number of delivery attempts, " +
|
||||||
|
"not retries on top of the first: a value of 3 makes three attempts " +
|
||||||
|
"in all. 0 means a single attempt with no retries and no circuit " +
|
||||||
|
"breaker."
|
||||||
|
|
||||||
|
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
||||||
|
// help text on both the create form (the add-target form on the webhook
|
||||||
|
// detail page) and the edit form, so the copy cannot drift back to
|
||||||
|
// calling the number a retry count.
|
||||||
|
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
|
webhook.ID = testWebhookID
|
||||||
|
|
||||||
|
entrypoint := database.Entrypoint{Path: "abc123"}
|
||||||
|
entrypoint.ID = "ep-1"
|
||||||
|
|
||||||
|
createBody := renderPage(
|
||||||
|
t, h, sess, "source_detail.html", map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
|
[]database.Entrypoint{entrypoint},
|
||||||
|
),
|
||||||
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
|
"Events": []database.Event{},
|
||||||
|
"BaseURL": "https://hooks.example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, createBody, maxRetriesHelp,
|
||||||
|
"the add-target form must explain max_retries as total attempts",
|
||||||
|
)
|
||||||
|
|
||||||
|
// A slack target exercises the same max_retries field while needing
|
||||||
|
// only Config.URL from the edit template, so the test data stays
|
||||||
|
// minimal. The Target key mirrors the field names the template reads
|
||||||
|
// off the handler's view value.
|
||||||
|
editBody := renderPage(
|
||||||
|
t, h, sess, "target_edit.html", map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
"Target": map[string]any{
|
||||||
|
"ID": "tg-1",
|
||||||
|
"Name": "t",
|
||||||
|
"Type": "slack",
|
||||||
|
"Active": true,
|
||||||
|
"MaxRetries": 3,
|
||||||
|
"Config": map[string]any{
|
||||||
|
"URL": "https://hooks.slack.com/services/x",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataKeyError: "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, editBody, maxRetriesHelp,
|
||||||
|
"the target edit form must explain max_retries as total attempts",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+129
-172
@@ -2,7 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
"sneak.berlin/go/webhooker/internal/signature"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -71,8 +70,12 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processWebhookRequest reads the body, verifies the sender,
|
// processWebhookRequest reads the body, serializes headers, loads
|
||||||
// serializes headers, loads targets, and delivers the event.
|
// targets, and delivers the event.
|
||||||
|
//
|
||||||
|
// Nothing about the request itself is authenticated: the entrypoint
|
||||||
|
// UUID in the path is the credential, and reaching here means it
|
||||||
|
// matched an active entrypoint.
|
||||||
func (h *Handlers) processWebhookRequest(
|
func (h *Handlers) processWebhookRequest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -83,26 +86,7 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before anything is written. An unverified request must leave no
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
// event row, no delivery row and no delivery task behind, so this
|
|
||||||
// sits above every write rather than inside the transaction that
|
|
||||||
// performs them. It has to sit below the body read because the
|
|
||||||
// signature is computed over the body; readWebhookBody is what
|
|
||||||
// bounds that read, so an unauthenticated sender still cannot make
|
|
||||||
// the process hold more than the 1 MB cap.
|
|
||||||
if !h.verifyInboundSignature(w, entrypoint, r.Header, body) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// These headers are about to be stored verbatim and handed to
|
|
||||||
// every delivery target, so the scheme's credential comes out
|
|
||||||
// first. Under GitLab's scheme the header is the shared secret
|
|
||||||
// itself, and leaving it in would hand the ability to forge
|
|
||||||
// signed requests to exactly the parties the signature is meant
|
|
||||||
// to exclude.
|
|
||||||
headersJSON, err := json.Marshal(
|
|
||||||
signature.SanitizeHeaders(&entrypoint, r.Header),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to serialize headers", err)
|
h.serverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
@@ -121,63 +105,6 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyInboundSignature authenticates the request against the
|
|
||||||
// entrypoint's configured secret, reporting false once it has written
|
|
||||||
// the response.
|
|
||||||
//
|
|
||||||
// An entrypoint with no secret configured is not checked and this
|
|
||||||
// returns true, which is the unchanged behaviour every existing
|
|
||||||
// entrypoint keeps.
|
|
||||||
//
|
|
||||||
// A configuration that cannot be applied — an unknown scheme, or one
|
|
||||||
// half of the pair missing — is a 500, not a 401: the request may well
|
|
||||||
// be authentic, and calling it unauthorized would tell a legitimate
|
|
||||||
// sender to go fix its own signing. Either way it is refused. Failing
|
|
||||||
// open here would mean an entrypoint the operator has protected
|
|
||||||
// quietly accepting anything.
|
|
||||||
func (h *Handlers) verifyInboundSignature(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
entrypoint database.Entrypoint,
|
|
||||||
header http.Header,
|
|
||||||
body []byte,
|
|
||||||
) bool {
|
|
||||||
err := signature.Verify(&entrypoint, header, body)
|
|
||||||
if err == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, signature.ErrConfig) {
|
|
||||||
h.log.Error(
|
|
||||||
"entrypoint signature configuration cannot be applied",
|
|
||||||
"entrypoint_id", entrypoint.ID,
|
|
||||||
"webhook_id", entrypoint.WebhookID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every field here is bounded and none is client-chosen: the ids
|
|
||||||
// are ours, the scheme is one of a fixed set, and the error is a
|
|
||||||
// static string carrying no part of the secret or of what the
|
|
||||||
// client presented. Reaching this line also requires a real
|
|
||||||
// entrypoint UUID, so it is not a line a stranger can drive.
|
|
||||||
h.log.Warn(
|
|
||||||
"inbound signature verification failed",
|
|
||||||
"entrypoint_id", entrypoint.ID,
|
|
||||||
"webhook_id", entrypoint.WebhookID,
|
|
||||||
"scheme", string(entrypoint.SignatureScheme),
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadActiveTargets returns all active targets for a webhook.
|
// loadActiveTargets returns all active targets for a webhook.
|
||||||
func (h *Handlers) loadActiveTargets(
|
func (h *Handlers) loadActiveTargets(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
@@ -255,8 +182,8 @@ func (h *Handlers) readWebhookBody(
|
|||||||
return body, true
|
return body, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndDeliverEvent creates the event and delivery records
|
// createAndDeliverEvent stores the received event, fans it out to the
|
||||||
// then notifies the delivery engine.
|
// webhook's targets, and answers the sender.
|
||||||
func (h *Handlers) createAndDeliverEvent(
|
func (h *Handlers) createAndDeliverEvent(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -264,69 +191,130 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
body, headersJSON []byte,
|
body, headersJSON []byte,
|
||||||
targets []database.Target,
|
targets []database.Target,
|
||||||
) {
|
) {
|
||||||
tx, err := h.beginWebhookTx(w, entrypoint.WebhookID)
|
event, tasks, err := h.createAndFanOut(
|
||||||
if err != nil {
|
requestEventSource(r, entrypoint, headersJSON, body),
|
||||||
return
|
targets,
|
||||||
}
|
|
||||||
|
|
||||||
event := h.buildEvent(r, entrypoint, headersJSON, body)
|
|
||||||
|
|
||||||
err = tx.Create(event).Error
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
h.serverError(w, "failed to create event", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyPtr := inlineBody(body)
|
|
||||||
|
|
||||||
tasks := h.buildDeliveryTasks(
|
|
||||||
w, tx, event, entrypoint, targets, bodyPtr,
|
|
||||||
)
|
)
|
||||||
if tasks == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit().Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to commit transaction", err)
|
h.serverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counted here, after the commit: an event is received once it
|
|
||||||
// is durably stored, which is what the delivery counters are
|
|
||||||
// compared against on a dashboard.
|
|
||||||
h.mtr.EventReceived()
|
|
||||||
|
|
||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// beginWebhookTx opens a transaction on the per-webhook DB.
|
// eventSource carries the fields a new event is built from. The
|
||||||
func (h *Handlers) beginWebhookTx(
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
w http.ResponseWriter,
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
webhookID string,
|
// event is constructed and fanned out in one place however it entered
|
||||||
) (*gorm.DB, error) {
|
// the system.
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
type eventSource struct {
|
||||||
if err != nil {
|
WebhookID string
|
||||||
h.serverError(
|
EntrypointID string
|
||||||
w, "failed to get webhook database", err,
|
Method string
|
||||||
)
|
HeadersJSON string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
|
||||||
return nil, err
|
// ResubmittedFromID names the event this one copies. Only the
|
||||||
|
// resubmit path sets it.
|
||||||
|
ResubmittedFromID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// event builds the row this source stores.
|
||||||
|
func (s eventSource) event() *database.Event {
|
||||||
|
return &database.Event{
|
||||||
|
WebhookID: s.WebhookID,
|
||||||
|
EntrypointID: s.EntrypointID,
|
||||||
|
Method: s.Method,
|
||||||
|
Headers: s.HeadersJSON,
|
||||||
|
Body: string(s.Body),
|
||||||
|
ContentType: s.ContentType,
|
||||||
|
ResubmittedFromID: s.ResubmittedFromID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestEventSource describes the event a live receiver request
|
||||||
|
// stores.
|
||||||
|
func requestEventSource(
|
||||||
|
r *http.Request,
|
||||||
|
entrypoint database.Entrypoint,
|
||||||
|
headersJSON, body []byte,
|
||||||
|
) eventSource {
|
||||||
|
return eventSource{
|
||||||
|
WebhookID: entrypoint.WebhookID,
|
||||||
|
EntrypointID: entrypoint.ID,
|
||||||
|
Method: r.Method,
|
||||||
|
HeadersJSON: string(headersJSON),
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAndFanOut writes the event and one pending delivery per target
|
||||||
|
// in a single transaction, then hands the tasks to the delivery
|
||||||
|
// engine. It is the only path by which an event and its deliveries are
|
||||||
|
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||||
|
// circuit-broken exactly as a received one is.
|
||||||
|
//
|
||||||
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
|
// many targets the event went to.
|
||||||
|
func (h *Handlers) createAndFanOut(
|
||||||
|
src eventSource,
|
||||||
|
targets []database.Target,
|
||||||
|
) (*database.Event, []delivery.Task, error) {
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(src.WebhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"getting webhook database: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx := webhookDB.Begin()
|
tx := webhookDB.Begin()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
h.serverError(
|
return nil, nil, fmt.Errorf(
|
||||||
w, "failed to begin transaction", tx.Error,
|
"beginning transaction: %w", tx.Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, tx.Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx, nil
|
event := src.event()
|
||||||
|
|
||||||
|
err = tx.Create(event).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("creating event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := buildDeliveryTasks(
|
||||||
|
tx, event, targets, inlineBody(src.Body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"committing transaction: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted here, after the commit: an event exists once it is
|
||||||
|
// durably stored, which is what the delivery counters are
|
||||||
|
// compared against on a dashboard. A resubmitted event counts
|
||||||
|
// too, because it produces deliveries that the delivery side
|
||||||
|
// counts; the resubmit counter is what separates the two.
|
||||||
|
h.mtr.EventReceived()
|
||||||
|
|
||||||
|
if len(tasks) > 0 {
|
||||||
|
h.notifier.Notify(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
return event, tasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inlineBody returns a pointer to body as a string if it fits
|
// inlineBody returns a pointer to body as a string if it fits
|
||||||
@@ -341,18 +329,13 @@ func inlineBody(body []byte) *string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishWebhookResponse notifies the delivery engine, logs the
|
// finishWebhookResponse logs the event and writes the HTTP response.
|
||||||
// event, and writes the HTTP response.
|
|
||||||
func (h *Handlers) finishWebhookResponse(
|
func (h *Handlers) finishWebhookResponse(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
entrypoint database.Entrypoint,
|
entrypoint database.Entrypoint,
|
||||||
tasks []delivery.Task,
|
tasks []delivery.Task,
|
||||||
) {
|
) {
|
||||||
if len(tasks) > 0 {
|
|
||||||
h.notifier.Notify(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.log.Info("webhook event created",
|
h.log.Info("webhook event created",
|
||||||
"event_id", event.ID,
|
"event_id", event.ID,
|
||||||
"webhook_id", entrypoint.WebhookID,
|
"webhook_id", entrypoint.WebhookID,
|
||||||
@@ -370,33 +353,15 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildEvent creates a new Event struct from request data.
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
func (h *Handlers) buildEvent(
|
// transaction and returns the tasks for the delivery engine. The
|
||||||
r *http.Request,
|
// caller owns the transaction and rolls it back on error.
|
||||||
entrypoint database.Entrypoint,
|
func buildDeliveryTasks(
|
||||||
headersJSON, body []byte,
|
|
||||||
) *database.Event {
|
|
||||||
return &database.Event{
|
|
||||||
WebhookID: entrypoint.WebhookID,
|
|
||||||
EntrypointID: entrypoint.ID,
|
|
||||||
Method: r.Method,
|
|
||||||
Headers: string(headersJSON),
|
|
||||||
Body: string(body),
|
|
||||||
ContentType: r.Header.Get("Content-Type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildDeliveryTasks creates delivery records in the
|
|
||||||
// transaction and returns tasks for the delivery engine.
|
|
||||||
// Returns nil if an error occurred.
|
|
||||||
func (h *Handlers) buildDeliveryTasks(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
entrypoint database.Entrypoint,
|
|
||||||
targets []database.Target,
|
targets []database.Target,
|
||||||
bodyPtr *string,
|
bodyPtr *string,
|
||||||
) []delivery.Task {
|
) ([]delivery.Task, error) {
|
||||||
tasks := make([]delivery.Task, 0, len(targets))
|
tasks := make([]delivery.Task, 0, len(targets))
|
||||||
|
|
||||||
for i := range targets {
|
for i := range targets {
|
||||||
@@ -408,25 +373,17 @@ func (h *Handlers) buildDeliveryTasks(
|
|||||||
|
|
||||||
err := tx.Create(dlv).Error
|
err := tx.Create(dlv).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
return nil, fmt.Errorf(
|
||||||
h.log.Error(
|
"creating delivery for target %s: %w",
|
||||||
"failed to create delivery",
|
targets[i].ID, err,
|
||||||
"target_id", targets[i].ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
WebhookID: entrypoint.WebhookID,
|
WebhookID: event.WebhookID,
|
||||||
EntrypointID: entrypoint.ID,
|
EntrypointID: event.EntrypointID,
|
||||||
TargetID: targets[i].ID,
|
TargetID: targets[i].ID,
|
||||||
TargetName: targets[i].Name,
|
TargetName: targets[i].Name,
|
||||||
TargetType: targets[i].Type,
|
TargetType: targets[i].Type,
|
||||||
@@ -440,5 +397,5 @@ func (h *Handlers) buildDeliveryTasks(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,468 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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/signature"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// inboundSecret is the shared secret the signed-receiver tests
|
|
||||||
// configure on their entrypoint. It doubles as a marker: no log
|
|
||||||
// line and no rendered page may contain it.
|
|
||||||
inboundSecret = "QQINBOUNDSECRETQQ"
|
|
||||||
|
|
||||||
// inboundBody is the payload the sender signs.
|
|
||||||
inboundBody = `{"zen":"Non-blocking is better than blocking."}`
|
|
||||||
|
|
||||||
// entrypointIDParam is the chi URL parameter naming an entrypoint.
|
|
||||||
entrypointIDParam = "entrypointID"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hubSignature returns the X-Hub-Signature-256 value a GitHub sender
|
|
||||||
// holding secret would send for inboundBody.
|
|
||||||
func hubSignature(secret string) string {
|
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
|
||||||
_, _ = mac.Write([]byte(inboundBody))
|
|
||||||
|
|
||||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedSignedEntrypoint inserts an active entrypoint for a webhook
|
|
||||||
// with the given signature configuration and returns it.
|
|
||||||
func seedSignedEntrypoint(
|
|
||||||
t *testing.T,
|
|
||||||
db *database.Database,
|
|
||||||
webhookID string,
|
|
||||||
scheme database.SignatureScheme,
|
|
||||||
secret string,
|
|
||||||
) *database.Entrypoint {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
Path: "path-" + webhookID,
|
|
||||||
Description: "signed",
|
|
||||||
Active: true,
|
|
||||||
SignatureScheme: scheme,
|
|
||||||
SignatureSecret: secret,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// postToEntrypoint drives the real receiver handler at an
|
|
||||||
// entrypoint's path with one optional header set.
|
|
||||||
func postToEntrypoint(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
path, body, headerName, headerValue string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/webhook/"+path,
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
if headerName != "" {
|
|
||||||
req.Header.Set(headerName, headerValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
rctx := chi.NewRouteContext()
|
|
||||||
rctx.URLParams.Add("uuid", path)
|
|
||||||
|
|
||||||
req = req.WithContext(
|
|
||||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleWebhook().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedEvents counts the event rows a webhook's per-webhook database
|
|
||||||
// holds. A database that was never opened holds none, which is the
|
|
||||||
// state a rejected request has to leave behind.
|
|
||||||
func storedEvents(
|
|
||||||
t *testing.T,
|
|
||||||
mgr *database.WebhookDBManager,
|
|
||||||
webhookID string,
|
|
||||||
) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if !mgr.DBExists(webhookID) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.Model(&database.Event{}).
|
|
||||||
Where("webhook_id = ?", webhookID).
|
|
||||||
Count(&count).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedEventHeaders reads back the Headers column of the single
|
|
||||||
// event row a webhook's per-webhook database holds.
|
|
||||||
//
|
|
||||||
// It reads the database rather than an in-memory struct on purpose:
|
|
||||||
// what matters is what an operator, a backup or the reaper's archive
|
|
||||||
// would find on disk, not what the handler passed around.
|
|
||||||
func storedEventHeaders(
|
|
||||||
t *testing.T,
|
|
||||||
mgr *database.WebhookDBManager,
|
|
||||||
webhookID string,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.True(t, mgr.DBExists(webhookID))
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var events []database.Event
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
db.Where("webhook_id = ?", webhookID).
|
|
||||||
Find(&events).Error,
|
|
||||||
)
|
|
||||||
require.Len(t, events, 1)
|
|
||||||
|
|
||||||
return events[0].Headers
|
|
||||||
}
|
|
||||||
|
|
||||||
// signedReceiverCase is one inbound request against an entrypoint
|
|
||||||
// with a given stored signature configuration.
|
|
||||||
type signedReceiverCase struct {
|
|
||||||
name string
|
|
||||||
scheme database.SignatureScheme
|
|
||||||
secret string
|
|
||||||
headerName string
|
|
||||||
headerValue string
|
|
||||||
body string
|
|
||||||
wantStatus int
|
|
||||||
}
|
|
||||||
|
|
||||||
// signedReceiverCases covers each supported scheme with a valid
|
|
||||||
// signature, an invalid one and none at all, plus the two states that
|
|
||||||
// are not "a client got it wrong": an entrypoint with nothing
|
|
||||||
// configured, and one whose stored configuration cannot be applied.
|
|
||||||
func signedReceiverCases() []signedReceiverCase {
|
|
||||||
return append(
|
|
||||||
schemeReceiverCases(), unverifiedReceiverCases()...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// schemeReceiverCases covers the two supported schemes.
|
|
||||||
func schemeReceiverCases() []signedReceiverCase {
|
|
||||||
return []signedReceiverCase{
|
|
||||||
{
|
|
||||||
name: "github valid",
|
|
||||||
scheme: database.SignatureSchemeGitHub,
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitHub,
|
|
||||||
headerValue: hubSignature(inboundSecret),
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "github wrong secret",
|
|
||||||
scheme: database.SignatureSchemeGitHub,
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitHub,
|
|
||||||
headerValue: hubSignature("wrong"),
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A digest that was valid for a different body: the
|
|
||||||
// check is over the bytes as received.
|
|
||||||
name: "github body tampered",
|
|
||||||
scheme: database.SignatureSchemeGitHub,
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitHub,
|
|
||||||
headerValue: hubSignature(inboundSecret),
|
|
||||||
body: inboundBody + " ",
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "github unsigned",
|
|
||||||
scheme: database.SignatureSchemeGitHub,
|
|
||||||
secret: inboundSecret,
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gitlab valid",
|
|
||||||
scheme: database.SignatureSchemeGitLab,
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitLab,
|
|
||||||
headerValue: inboundSecret,
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gitlab wrong token",
|
|
||||||
scheme: database.SignatureSchemeGitLab,
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitLab,
|
|
||||||
headerValue: "wrong",
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gitlab unsigned",
|
|
||||||
scheme: database.SignatureSchemeGitLab,
|
|
||||||
secret: inboundSecret,
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unverifiedReceiverCases covers the two entrypoint states that are
|
|
||||||
// not about a client getting its signature wrong: nothing configured
|
|
||||||
// at all, and a configuration the receiver cannot apply.
|
|
||||||
func unverifiedReceiverCases() []signedReceiverCase {
|
|
||||||
return []signedReceiverCase{
|
|
||||||
{
|
|
||||||
// The pass-through case. An entrypoint with nothing
|
|
||||||
// configured is what every deployment already has, and
|
|
||||||
// it must keep accepting unsigned requests so that an
|
|
||||||
// upgrade does not lock an operator out of their own
|
|
||||||
// receivers.
|
|
||||||
name: "unconfigured accepts unsigned",
|
|
||||||
scheme: database.SignatureSchemeNone,
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A stray signature header changes nothing when nothing
|
|
||||||
// is configured to check it.
|
|
||||||
name: "unconfigured ignores a stray header",
|
|
||||||
scheme: database.SignatureSchemeNone,
|
|
||||||
headerName: signature.HeaderGitHub,
|
|
||||||
headerValue: "sha256=deadbeef",
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A scheme this build cannot apply, reachable only by
|
|
||||||
// editing the database: refused, not waved through as
|
|
||||||
// unverified.
|
|
||||||
name: "unknown scheme fails closed",
|
|
||||||
scheme: database.SignatureScheme("stripe"),
|
|
||||||
secret: inboundSecret,
|
|
||||||
headerName: signature.HeaderGitHub,
|
|
||||||
headerValue: hubSignature(inboundSecret),
|
|
||||||
body: inboundBody,
|
|
||||||
wantStatus: http.StatusInternalServerError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReceiverVerifiesConfiguredEntrypoints is the load-bearing test
|
|
||||||
// for the feature: for each supported scheme a correctly signed
|
|
||||||
// request is accepted and stored, and an incorrectly signed or
|
|
||||||
// unsigned one is answered 401 having stored nothing.
|
|
||||||
//
|
|
||||||
// The event count is the half that matters most. A rejection that
|
|
||||||
// still wrote a row would leave the receiver a place for a stranger
|
|
||||||
// who knows a URL to deposit content, which is exactly what the
|
|
||||||
// signature is there to prevent.
|
|
||||||
//
|
|
||||||
// The cases share one application and take a webhook each, rather
|
|
||||||
// than each standing up its own: every newTestApp seeds an admin user
|
|
||||||
// and so pays an Argon2id hash at 64 MB, and this package's test
|
|
||||||
// budget is not large enough to spend one per table row.
|
|
||||||
func TestReceiverVerifiesConfiguredEntrypoints(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
mgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db, &mgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
for _, tc := range signedReceiverCases() {
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
ep := seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID, tc.scheme, tc.secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := postToEntrypoint(
|
|
||||||
t, h, ep.Path, tc.body,
|
|
||||||
tc.headerName, tc.headerValue,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, tc.wantStatus, w.Code, "case %s", tc.name)
|
|
||||||
|
|
||||||
want := int64(0)
|
|
||||||
if tc.wantStatus == http.StatusOK {
|
|
||||||
want = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, want, storedEvents(t, mgr, wh.ID),
|
|
||||||
"case %s: stored event rows after a %d response",
|
|
||||||
tc.name, w.Code,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReceiverLogsNoSecret proves the rejection path does not write
|
|
||||||
// the shared secret, or what the client presented, into the log. A
|
|
||||||
// GitLab token arrives as the credential itself, so echoing the
|
|
||||||
// header value would put a live secret in the log of every deployment
|
|
||||||
// whose sender is briefly misconfigured.
|
|
||||||
func TestReceiverLogsNoSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const presented = "QQPRESENTEDVALUEQQ"
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
h.SetLogForTest(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
ep := seedSignedEntrypoint(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.SignatureSchemeGitLab, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := postToEntrypoint(
|
|
||||||
t, h, ep.Path, inboundBody,
|
|
||||||
signature.HeaderGitLab, presented,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
||||||
|
|
||||||
// The rejection is recorded at all — a silent 401 leaves an
|
|
||||||
// operator no way to see a sender failing to authenticate.
|
|
||||||
assert.Contains(t, buf.String(), "verification failed")
|
|
||||||
assert.NotContains(t, buf.String(), inboundSecret)
|
|
||||||
assert.NotContains(t, buf.String(), presented)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReceiverDoesNotStoreInboundCredential proves an accepted
|
|
||||||
// request leaves no copy of the shared secret in the event store.
|
|
||||||
//
|
|
||||||
// GitLab's X-Gitlab-Token is the credential itself, not a digest
|
|
||||||
// over the request. Stored headers are read back by the UI, copied
|
|
||||||
// into every backup and archive, and handed verbatim to every
|
|
||||||
// delivery target, so a stored token is the entrypoint's only
|
|
||||||
// authentication control disclosed to precisely the parties it
|
|
||||||
// exists to exclude.
|
|
||||||
//
|
|
||||||
// The two cases share one application: every newTestApp seeds an
|
|
||||||
// admin user and pays an Argon2id hash at 64 MB, and this package's
|
|
||||||
// test budget does not stretch to one per case.
|
|
||||||
func TestReceiverDoesNotStoreInboundCredential(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
mgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db, &mgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
gitlab := seedWebhook(t, db)
|
|
||||||
gitlabEP := seedSignedEntrypoint(
|
|
||||||
t, db, gitlab.ID,
|
|
||||||
database.SignatureSchemeGitLab, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := postToEntrypoint(
|
|
||||||
t, h, gitlabEP.Path, inboundBody,
|
|
||||||
signature.HeaderGitLab, inboundSecret,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
stored := storedEventHeaders(t, mgr, gitlab.ID)
|
|
||||||
|
|
||||||
assert.NotContains(
|
|
||||||
t, stored, inboundSecret,
|
|
||||||
"the shared secret must not be persisted",
|
|
||||||
)
|
|
||||||
assert.NotContains(
|
|
||||||
t, stored, signature.HeaderGitLab,
|
|
||||||
"the credential header must not be persisted at all",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Everything else the sender set is still there. A fix that
|
|
||||||
// stored no headers would satisfy the assertions above while
|
|
||||||
// discarding the record the receiver exists to keep.
|
|
||||||
assert.Contains(t, stored, "Content-Type")
|
|
||||||
|
|
||||||
// A GitHub digest is an HMAC over the body, so the key cannot be
|
|
||||||
// recovered from it and it stays: the stripping is scoped to
|
|
||||||
// what actually carries the secret.
|
|
||||||
github := seedWebhook(t, db)
|
|
||||||
githubEP := seedSignedEntrypoint(
|
|
||||||
t, db, github.ID,
|
|
||||||
database.SignatureSchemeGitHub, inboundSecret,
|
|
||||||
)
|
|
||||||
|
|
||||||
w = postToEntrypoint(
|
|
||||||
t, h, githubEP.Path, inboundBody,
|
|
||||||
signature.HeaderGitHub, hubSignature(inboundSecret),
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
stored = storedEventHeaders(t, mgr, github.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, stored, signature.HeaderGitHub)
|
|
||||||
assert.NotContains(t, stored, inboundSecret)
|
|
||||||
}
|
|
||||||
@@ -83,6 +83,7 @@ type Set struct {
|
|||||||
deliveriesFailed *prometheus.CounterVec
|
deliveriesFailed *prometheus.CounterVec
|
||||||
deliveryRetries *prometheus.CounterVec
|
deliveryRetries *prometheus.CounterVec
|
||||||
deliveryReplays *prometheus.CounterVec
|
deliveryReplays *prometheus.CounterVec
|
||||||
|
eventsResubmitted prometheus.Counter
|
||||||
deliveryDuration *prometheus.HistogramVec
|
deliveryDuration *prometheus.HistogramVec
|
||||||
deliveriesPending *prometheus.GaugeVec
|
deliveriesPending *prometheus.GaugeVec
|
||||||
deliveriesRetrying *prometheus.GaugeVec
|
deliveriesRetrying *prometheus.GaugeVec
|
||||||
@@ -166,6 +167,23 @@ func (s *Set) DeliveryReplayed(t database.TargetType) {
|
|||||||
Inc()
|
Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventResubmitted counts one stored event an operator re-injected
|
||||||
|
// from the event log.
|
||||||
|
//
|
||||||
|
// It counts the operator action once, not the deliveries it fans out
|
||||||
|
// to: those already move the attempt, outcome and duration series, and
|
||||||
|
// the new event moves events_received_total, since it is a stored
|
||||||
|
// event that the delivery side will be compared against. This counter
|
||||||
|
// is what separates a resubmitted event from a received one.
|
||||||
|
//
|
||||||
|
// It carries no labels. The only label available at the call site
|
||||||
|
// would be the route pattern, which has exactly one value and so would
|
||||||
|
// distinguish nothing; the target types the event fans out to belong
|
||||||
|
// to the delivery series, not to this one.
|
||||||
|
func (s *Set) EventResubmitted() {
|
||||||
|
s.eventsResubmitted.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
// DeliveryStatusChanged counts a delivery's transition into a new
|
// DeliveryStatusChanged counts a delivery's transition into a new
|
||||||
// status. The mapping from status to counter lives here, next to the
|
// status. The mapping from status to counter lives here, next to the
|
||||||
// collectors, so the engine has a single call for every transition it
|
// collectors, so the engine has a single call for every transition it
|
||||||
@@ -298,6 +316,15 @@ func (s *Set) registerCounters(factory promauto.Factory) {
|
|||||||
},
|
},
|
||||||
[]string{targetTypeLabel},
|
[]string{targetTypeLabel},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
s.eventsResubmitted = factory.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: "events_resubmitted_total",
|
||||||
|
Help: "Stored events an operator re-injected from " +
|
||||||
|
"the event log as new events.",
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Set) registerGauges(factory promauto.Factory) {
|
func (s *Set) registerGauges(factory promauto.Factory) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
|
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSRFToken retrieves the CSRF token from the request context.
|
// CSRFToken retrieves the CSRF token from the request context.
|
||||||
@@ -13,13 +14,6 @@ func CSRFToken(r *http.Request) string {
|
|||||||
return csrf.Token(r)
|
return csrf.Token(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isClientTLS reports whether the client-facing connection uses TLS.
|
|
||||||
// It checks for a direct TLS connection (r.TLS) or a TLS-terminating
|
|
||||||
// reverse proxy that sets the standard X-Forwarded-Proto header.
|
|
||||||
func isClientTLS(r *http.Request) bool {
|
|
||||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF returns middleware that provides CSRF protection using the
|
// CSRF returns middleware that provides CSRF protection using the
|
||||||
// gorilla/csrf library. The middleware uses the session authentication
|
// gorilla/csrf library. The middleware uses the session authentication
|
||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
@@ -27,9 +21,10 @@ func isClientTLS(r *http.Request) bool {
|
|||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token receive a 403 Forbidden response.
|
// token receive a 403 Forbidden response.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol per-request
|
// The middleware detects the client-facing transport protocol
|
||||||
// using r.TLS and the X-Forwarded-Proto header. This allows correct
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
// behavior in all deployment scenarios:
|
// cookie also uses. This allows correct behavior in all deployment
|
||||||
|
// scenarios:
|
||||||
//
|
//
|
||||||
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
|
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
|
||||||
// - Behind a TLS-terminating reverse proxy: strict checks (the
|
// - Behind a TLS-terminating reverse proxy: strict checks (the
|
||||||
@@ -83,7 +78,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
|||||||
httpCSRF := httpProtect(next)
|
httpCSRF := httpProtect(next)
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if isClientTLS(r) {
|
if reqtls.IsTLS(r) {
|
||||||
// Client is on TLS (directly or via reverse proxy).
|
// Client is on TLS (directly or via reverse proxy).
|
||||||
// Use Secure cookies and strict Origin/Referer checks.
|
// Use Secure cookies and strict Origin/Referer checks.
|
||||||
tlsCSRF.ServeHTTP(w, r)
|
tlsCSRF.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -297,55 +297,175 @@ func TestCSRFToken_NoMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- TLS Detection Tests ---
|
// --- TLS Detection Tests ---
|
||||||
|
//
|
||||||
|
// The predicate itself is tested in internal/reqtls. What is tested
|
||||||
|
// here is the consequence that actually matters: which of the two
|
||||||
|
// gorilla/csrf instances a request is routed to.
|
||||||
|
//
|
||||||
|
// The two are told apart behaviourally rather than by inspection. On
|
||||||
|
// the STRICT (TLS) instance, a state-changing request carrying no
|
||||||
|
// Origin header must supply a Referer -- gorilla/csrf rejects it with
|
||||||
|
// ErrNoReferer before it ever looks at the token, to defend a
|
||||||
|
// TLS site against an HTTP machine-in-the-middle injecting a form. On
|
||||||
|
// the RELAXED (plaintext) instance that check is skipped and a valid
|
||||||
|
// token is enough. So: valid token, no Origin, no Referer, and the
|
||||||
|
// outcome names the instance.
|
||||||
|
//
|
||||||
|
// Landing on the relaxed instance for a genuinely-HTTPS deployment is
|
||||||
|
// the defect: an exact == "https" comparison did exactly that for the
|
||||||
|
// uppercase and comma-appended spellings below.
|
||||||
|
|
||||||
func TestIsClientTLS_DirectTLS(t *testing.T) {
|
// csrfTookStrictPath reports whether the CSRF middleware routed a
|
||||||
|
// request with the given transport to the strict instance. It also
|
||||||
|
// asserts the CSRF cookie's Secure attribute agrees, since the two are
|
||||||
|
// set by the same choice and must never disagree.
|
||||||
|
func csrfTookStrictPath(
|
||||||
|
t *testing.T,
|
||||||
|
env string,
|
||||||
|
directTLS bool,
|
||||||
|
fwdProto string,
|
||||||
|
) bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, env)
|
||||||
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
|
newReq := func(method string) *http.Request {
|
||||||
|
r := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), method,
|
||||||
|
"http://example.com/form", nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
if directTLS {
|
||||||
|
r.TLS = &tls.ConnectionState{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fwdProto != "" {
|
||||||
|
r.Header.Set("X-Forwarded-Proto", fwdProto)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
token, cookies := csrfGetToken(t, csrfMW, newReq(http.MethodGet))
|
||||||
|
|
||||||
|
// Deliberately no Origin and no Referer: that is what makes the
|
||||||
|
// two instances distinguishable.
|
||||||
|
called, code := csrfPostWithToken(
|
||||||
|
t, csrfMW, newReq(http.MethodPost), token, cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
strict := !called
|
||||||
|
|
||||||
|
if strict {
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusForbidden, code,
|
||||||
|
"the strict instance rejects a Referer-less POST",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
if c.Name == csrfCookieName {
|
||||||
|
assert.Equal(
|
||||||
|
t, strict, c.Secure,
|
||||||
|
"the CSRF cookie's Secure attribute and the "+
|
||||||
|
"chosen instance come from one decision "+
|
||||||
|
"and must agree",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strict
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header
|
||||||
|
// spellings a real proxy emits through the middleware. The environment
|
||||||
|
// is set to dev -- the permissive setting -- to pin that the routing is
|
||||||
|
// a per-request transport decision and owes nothing to configuration.
|
||||||
|
func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := httptest.NewRequestWithContext(
|
cases := []struct {
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
name string
|
||||||
r.TLS = &tls.ConnectionState{}
|
header string
|
||||||
|
strict bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "lowercase",
|
||||||
|
header: "https",
|
||||||
|
strict: true,
|
||||||
|
why: "the ordinary spelling",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase",
|
||||||
|
header: "HTTPS",
|
||||||
|
strict: true,
|
||||||
|
why: "the header value is a case-insensitive token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with plaintext inner hop",
|
||||||
|
header: "https, http",
|
||||||
|
strict: true,
|
||||||
|
why: "a chained proxy appends its hop; the leftmost " +
|
||||||
|
"element is the browser's connection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain of two TLS hops",
|
||||||
|
header: "https,https",
|
||||||
|
strict: true,
|
||||||
|
why: "appended chain with no space after the comma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing space",
|
||||||
|
header: "https ",
|
||||||
|
strict: true,
|
||||||
|
why: "whitespace is not part of the token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plaintext",
|
||||||
|
header: "http",
|
||||||
|
strict: false,
|
||||||
|
why: "the negative control: the proxy reports a " +
|
||||||
|
"plaintext client connection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, tc.strict,
|
||||||
|
csrfTookStrictPath(
|
||||||
|
t, config.EnvironmentDev, false, tc.header,
|
||||||
|
),
|
||||||
|
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSRF_DirectTLSTakesStrictPath covers the no-proxy TLS
|
||||||
|
// deployment, and TestCSRF_PlaintextTakesRelaxedPath the no-proxy
|
||||||
|
// plaintext one -- the local development case that must keep working.
|
||||||
|
func TestCSRF_DirectTLSTakesStrictPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, middleware.IsClientTLS(r),
|
t,
|
||||||
"should detect direct TLS connection",
|
csrfTookStrictPath(t, config.EnvironmentDev, true, ""),
|
||||||
|
"a request that arrived over TLS takes the strict path",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsClientTLS_XForwardedProto(t *testing.T) {
|
func TestCSRF_PlaintextTakesRelaxedPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
|
||||||
r.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, middleware.IsClientTLS(r),
|
|
||||||
"should detect TLS via X-Forwarded-Proto",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsClientTLS_PlaintextHTTP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, middleware.IsClientTLS(r),
|
t,
|
||||||
"should detect plaintext HTTP",
|
csrfTookStrictPath(t, config.EnvironmentProd, false, ""),
|
||||||
)
|
"no TLS and no proxy header is plaintext, in any environment",
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsClientTLS_XForwardedProtoHTTP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
|
||||||
r.Header.Set("X-Forwarded-Proto", "http")
|
|
||||||
|
|
||||||
assert.False(
|
|
||||||
t, middleware.IsClientTLS(r),
|
|
||||||
"should detect plaintext when X-Forwarded-Proto is http",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,32 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MetricsMiddlewareForTest builds the metrics recording middleware
|
||||||
|
// against a caller-supplied recorder, so a test can gather from its
|
||||||
|
// own Prometheus registry rather than the process-wide default one
|
||||||
|
// that Middleware.Metrics uses.
|
||||||
|
func MetricsMiddlewareForTest(
|
||||||
|
rec httpmetrics.Recorder,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
return metricsMiddleware(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmatchedRouteConst exposes the sentinel that stands in for a
|
||||||
|
// request matching no route pattern.
|
||||||
|
const UnmatchedRouteConst = unmatchedRoute
|
||||||
|
|
||||||
|
// InflightHandlerConst exposes the fixed handler label on the
|
||||||
|
// inflight gauge.
|
||||||
|
const InflightHandlerConst = inflightHandler
|
||||||
|
|
||||||
|
// UnmatchedMethodConst exposes the sentinel that stands in for a
|
||||||
|
// method the router can never route.
|
||||||
|
const UnmatchedMethodConst = unmatchedMethod
|
||||||
|
|
||||||
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
|
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
|
||||||
// for use in external test packages.
|
// for use in external test packages.
|
||||||
func NewLoggingResponseWriterForTest(
|
func NewLoggingResponseWriterForTest(
|
||||||
@@ -32,11 +56,6 @@ func ClientKeyForTest(m *Middleware, r *http.Request) string {
|
|||||||
return m.clientKey(r)
|
return m.clientKey(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsClientTLS exposes isClientTLS for testing.
|
|
||||||
func IsClientTLS(r *http.Request) bool {
|
|
||||||
return isClientTLS(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRateLimitConst exposes the loginRateLimit constant: the
|
// LoginRateLimitConst exposes the loginRateLimit constant: the
|
||||||
// number of FAILED login attempts one client may make against one
|
// number of FAILED login attempts one client may make against one
|
||||||
// submitted username per interval.
|
// submitted username per interval.
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||||
|
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inflightHandler is the fixed `handler` label on
|
||||||
|
// http_requests_inflight, the one HTTP metric here that cannot carry
|
||||||
|
// a route pattern.
|
||||||
|
//
|
||||||
|
// The gauge is incremented before the wrapped handler runs and
|
||||||
|
// decremented after it returns, and the pattern only exists between
|
||||||
|
// those two moments. Deriving the label from the route would
|
||||||
|
// therefore increment one series and decrement another, leaving every
|
||||||
|
// pattern permanently off by the number of requests it served — a
|
||||||
|
// broken gauge, on top of the per-path cardinality this file exists
|
||||||
|
// to remove. So the gauge is deliberately aggregate: one series,
|
||||||
|
// counting the requests in flight across the whole service.
|
||||||
|
const inflightHandler = "(all)"
|
||||||
|
|
||||||
|
// unmatchedMethod is the `method` label for a request whose method
|
||||||
|
// the router can never route.
|
||||||
|
//
|
||||||
|
// It is deliberately the same sentinel as unmatchedRoute rather than
|
||||||
|
// a spelling of its own: both stand for a client-chosen token that
|
||||||
|
// matched nothing this service registers, and giving one idea two
|
||||||
|
// spellings would read in a scrape as two different unmatched states.
|
||||||
|
const unmatchedMethod = unmatchedRoute
|
||||||
|
|
||||||
|
// routePatternID is the `handler` label for a request: the chi route
|
||||||
|
// pattern, never the concrete path.
|
||||||
|
//
|
||||||
|
// The pattern is what bounds the label's domain to the routes the
|
||||||
|
// service registers. The path does not bound it at all — every byte
|
||||||
|
// after /webhook/ is client-chosen, so labelling by path lets any
|
||||||
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
|
// while doing it.
|
||||||
|
//
|
||||||
|
// chi populates the route context during routeHTTP, so this is only
|
||||||
|
// valid once routing has run. Every caller below is on the recording
|
||||||
|
// side of the middleware, which go-http-metrics defers until after
|
||||||
|
// the wrapped handler returns.
|
||||||
|
func routePatternID(ctx context.Context) string {
|
||||||
|
if rc := chi.RouteContext(ctx); rc != nil {
|
||||||
|
if pattern := rc.RoutePattern(); pattern != "" {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unmatchedRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodID is the `method` label for a request: the request method
|
||||||
|
// when the router can route it, and the unmatched sentinel otherwise.
|
||||||
|
//
|
||||||
|
// net/http accepts any RFC 9110 token as a method and hands it
|
||||||
|
// through verbatim, so the raw method is client-chosen bytes and
|
||||||
|
// bounds the label at nothing — the same unauthenticated
|
||||||
|
// series-minting the handler label carried, reached through a second
|
||||||
|
// dimension. What bounds it is the set chi's router will match a
|
||||||
|
// route for: its methodMap, which is unexported, so it is restated
|
||||||
|
// here against the net/http constants it is built from. A token
|
||||||
|
// outside that set can only ever produce chi's 405, so folding every
|
||||||
|
// one of them onto a single series loses no information a scrape
|
||||||
|
// could have used, while the nine methods that can reach a handler
|
||||||
|
// stay distinguishable.
|
||||||
|
//
|
||||||
|
// chi.RegisterMethod would extend the router's set at runtime; this
|
||||||
|
// service never calls it, and a caller that started to would have to
|
||||||
|
// extend this switch with it.
|
||||||
|
func methodID(method string) string {
|
||||||
|
switch method {
|
||||||
|
case http.MethodConnect,
|
||||||
|
http.MethodDelete,
|
||||||
|
http.MethodGet,
|
||||||
|
http.MethodHead,
|
||||||
|
http.MethodOptions,
|
||||||
|
http.MethodPatch,
|
||||||
|
http.MethodPost,
|
||||||
|
http.MethodPut,
|
||||||
|
http.MethodTrace:
|
||||||
|
return method
|
||||||
|
default:
|
||||||
|
return unmatchedMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// boundedLabelRecorder wraps a go-http-metrics recorder and replaces
|
||||||
|
// the request-controlled labels on every observation with bounded
|
||||||
|
// ones: the handler id becomes the request's route pattern, and the
|
||||||
|
// method becomes one the router can route.
|
||||||
|
//
|
||||||
|
// This is the seam that makes the pattern usable at all. The metrics
|
||||||
|
// middleware is global (see Server.setupGlobalMiddleware), so it is
|
||||||
|
// entered before chi has matched anything, and go-http-metrics fixes
|
||||||
|
// its handler id up front — passing the pattern in as that id is not
|
||||||
|
// possible, and leaving the id empty makes the library substitute the
|
||||||
|
// raw URL path, which is the defect. What the library does hand over
|
||||||
|
// is the request context, unchanged, on each recorder call; that
|
||||||
|
// context carries the same *chi.Context pointer routing mutates in
|
||||||
|
// place, and the duration and size calls happen after the wrapped
|
||||||
|
// handler has returned. Reading the pattern there is what the access
|
||||||
|
// log already does in accessLogURL.
|
||||||
|
//
|
||||||
|
// Recording after the whole chain returns is also what makes this
|
||||||
|
// hold for requests the route-level receiver rate limiter rejects.
|
||||||
|
// Those never reach a handler, but chi has already matched the route
|
||||||
|
// by the time the limiter runs, so their 429s land on the pattern
|
||||||
|
// like any other response.
|
||||||
|
type boundedLabelRecorder struct {
|
||||||
|
inner httpmetrics.Recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r boundedLabelRecorder) ObserveHTTPRequestDuration(
|
||||||
|
ctx context.Context,
|
||||||
|
props httpmetrics.HTTPReqProperties,
|
||||||
|
duration time.Duration,
|
||||||
|
) {
|
||||||
|
props.ID = routePatternID(ctx)
|
||||||
|
props.Method = methodID(props.Method)
|
||||||
|
r.inner.ObserveHTTPRequestDuration(ctx, props, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r boundedLabelRecorder) ObserveHTTPResponseSize(
|
||||||
|
ctx context.Context,
|
||||||
|
props httpmetrics.HTTPReqProperties,
|
||||||
|
sizeBytes int64,
|
||||||
|
) {
|
||||||
|
props.ID = routePatternID(ctx)
|
||||||
|
props.Method = methodID(props.Method)
|
||||||
|
r.inner.ObserveHTTPResponseSize(ctx, props, sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r boundedLabelRecorder) AddInflightRequests(
|
||||||
|
ctx context.Context,
|
||||||
|
props httpmetrics.HTTPProperties,
|
||||||
|
quantity int,
|
||||||
|
) {
|
||||||
|
props.ID = inflightHandler
|
||||||
|
r.inner.AddInflightRequests(ctx, props, quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||||
|
|
||||||
|
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||||
|
// the default registry, which is the one the /metrics route gathers.
|
||||||
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
|
return metricsMiddleware(
|
||||||
|
prommetrics.NewRecorder(prommetrics.Config{}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsMiddleware builds the recording middleware against a given
|
||||||
|
// recorder, so tests can gather from a registry of their own instead
|
||||||
|
// of the process-wide default.
|
||||||
|
func metricsMiddleware(
|
||||||
|
rec httpmetrics.Recorder,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
mdlw := ghmm.New(ghmm.Config{
|
||||||
|
Recorder: boundedLabelRecorder{inner: rec},
|
||||||
|
})
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
// The handler id is unmatchedRoute rather than "" so that
|
||||||
|
// the client-chosen URL path never enters the metrics
|
||||||
|
// pipeline at all: an empty id is the library's signal to
|
||||||
|
// substitute it. boundedLabelRecorder overwrites this value
|
||||||
|
// on every observation, so it is reachable only if that
|
||||||
|
// decorator is removed — in which case the metrics collapse
|
||||||
|
// to one series instead of leaking again.
|
||||||
|
return std.Handler(unmatchedRoute, mdlw, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// metricsProbeMethods is how many distinct invented method tokens
|
||||||
|
// each cardinality assertion drives. The measurement on the issue
|
||||||
|
// took 300 tokens from 106 exposition lines to 7,631 — about 25
|
||||||
|
// permanent lines per token, never reclaimed — so a probe of this
|
||||||
|
// size puts a regression thousands of lines over the bound rather
|
||||||
|
// than leaving it to a rounding argument.
|
||||||
|
metricsProbeMethods = 300
|
||||||
|
|
||||||
|
// probeMethodLen is how many characters each invented method
|
||||||
|
// token carries, matching the 12 the issue measured with.
|
||||||
|
probeMethodLen = 12
|
||||||
|
|
||||||
|
// methodLabel is the label these tests are about.
|
||||||
|
methodLabel = "method"
|
||||||
|
)
|
||||||
|
|
||||||
|
// realMethods is the positive control's domain: the methods chi's
|
||||||
|
// router can match a route for, every one of which a client
|
||||||
|
// legitimately sends and every one of which must keep a series of its
|
||||||
|
// own. Bounding the label by collapsing these into one bucket would
|
||||||
|
// destroy the metric it is meant to protect.
|
||||||
|
func realMethods() []string {
|
||||||
|
return []string{
|
||||||
|
http.MethodConnect, http.MethodDelete, http.MethodGet,
|
||||||
|
http.MethodHead, http.MethodOptions, http.MethodPatch,
|
||||||
|
http.MethodPost, http.MethodPut, http.MethodTrace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodProbePath returns the one receiver path a method probe
|
||||||
|
// targets. Holding the path fixed leaves the method as the only
|
||||||
|
// dimension varying, so any series growth a probe produces is the
|
||||||
|
// method label's and nothing else's.
|
||||||
|
func methodProbePath() string {
|
||||||
|
return "/webhook/" + uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||||
|
// router will ever match: uppercase hex from a fresh UUID, which is
|
||||||
|
// both the shape and the length an unauthenticated flood would send.
|
||||||
|
// net/http accepts any token as a method, so every one of these
|
||||||
|
// reaches the metrics pipeline exactly as a real method does.
|
||||||
|
func inventedMethods(n int) []string {
|
||||||
|
methods := make([]string, 0, n)
|
||||||
|
|
||||||
|
for range n {
|
||||||
|
token := strings.ToUpper(
|
||||||
|
strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||||
|
)
|
||||||
|
methods = append(methods, token[:probeMethodLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
return methods
|
||||||
|
}
|
||||||
|
|
||||||
|
// driveMethods sends one request per supplied method to a single
|
||||||
|
// fixed path.
|
||||||
|
func driveMethods(
|
||||||
|
t *testing.T,
|
||||||
|
h http.Handler,
|
||||||
|
path string,
|
||||||
|
methods []string,
|
||||||
|
) map[int]int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
probes := make([]probe, 0, len(methods))
|
||||||
|
|
||||||
|
for _, m := range methods {
|
||||||
|
probes = append(probes, probe{method: m, path: path})
|
||||||
|
}
|
||||||
|
|
||||||
|
return drive(t, h, probes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodLabels returns the set of distinct `method` values across
|
||||||
|
// every gathered series that carries the label at all. The inflight
|
||||||
|
// gauge does not carry it, and so contributes nothing rather than an
|
||||||
|
// empty-string member.
|
||||||
|
func methodLabels(families []*dto.MetricFamily) map[string]struct{} {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, fam := range families {
|
||||||
|
for _, m := range fam.GetMetric() {
|
||||||
|
for _, pair := range m.GetLabel() {
|
||||||
|
if pair.GetName() == methodLabel {
|
||||||
|
seen[pair.GetValue()] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrapeLines renders the registry through the same promhttp handler
|
||||||
|
// /metrics is mounted on and counts the sample lines it produced.
|
||||||
|
//
|
||||||
|
// This is the quantity the issue measured and the one a Prometheus
|
||||||
|
// server pays for on every scrape: one histogram label set is a
|
||||||
|
// single gathered series but around 25 lines of exposition, which is
|
||||||
|
// why 300 method tokens cost thousands of lines rather than hundreds.
|
||||||
|
func scrapeLines(t *testing.T, reg *prometheus.Registry) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
h := promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/metrics", nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
lines := 0
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(w.Body.String(), "\n") {
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lines++
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_MethodSentinelIsTheRouteSentinel pins the convention
|
||||||
|
// rather than the mechanism. An unroutable method and an unmatched
|
||||||
|
// path are the same fact — a client-chosen token matching nothing
|
||||||
|
// this service registers — so they carry one spelling. Two spellings
|
||||||
|
// would read in a scrape as two different unmatched states.
|
||||||
|
func TestMetrics_MethodSentinelIsTheRouteSentinel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
middleware.UnmatchedRouteConst,
|
||||||
|
middleware.UnmatchedMethodConst,
|
||||||
|
"the unmatched sentinel must have exactly one spelling",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_InventedMethodsMintOneLabelSet is the direct assertion
|
||||||
|
// the issue asks for: N requests carrying N distinct invented method
|
||||||
|
// tokens must produce exactly ONE method label. Before the fix this
|
||||||
|
// produced N of them, on an unauthenticated route with no rate
|
||||||
|
// limiter.
|
||||||
|
func TestMetrics_InventedMethodsMintOneLabelSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
methods := inventedMethods(metricsProbeMethods)
|
||||||
|
|
||||||
|
codes := driveMethods(t, h, methodProbePath(), methods)
|
||||||
|
require.Equal(
|
||||||
|
t, metricsProbeMethods, codes[http.StatusMethodNotAllowed],
|
||||||
|
"every invented token should have been unroutable",
|
||||||
|
)
|
||||||
|
|
||||||
|
labels := methodLabels(gatherMetrics(t, reg))
|
||||||
|
|
||||||
|
// Asserted on the count rather than on the set, so that a
|
||||||
|
// regression reports one number instead of dumping every token it
|
||||||
|
// minted.
|
||||||
|
distinct := len(labels)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, 1, distinct,
|
||||||
|
"invented methods must collapse onto one label",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, keys(labels), middleware.UnmatchedMethodConst,
|
||||||
|
"that one label must be the unmatched sentinel",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The scrape must not republish the tokens it was driven with
|
||||||
|
// either: a label that merely looks bounded while still echoing
|
||||||
|
// client bytes is the same defect wearing a different name.
|
||||||
|
echoed := 0
|
||||||
|
|
||||||
|
for _, m := range methods {
|
||||||
|
for label := range labels {
|
||||||
|
if strings.Contains(label, m) {
|
||||||
|
echoed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, 0, echoed,
|
||||||
|
"invented method tokens reached the metrics labels",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_MethodSeriesCountIsFlatUnderAFlood reproduces the
|
||||||
|
// measurement on the issue in miniature: scrape, drive several
|
||||||
|
// hundred distinct method tokens, scrape again, and require the
|
||||||
|
// second scrape to be no larger than the first. The first batch
|
||||||
|
// establishes every label set the route can produce; a flood five
|
||||||
|
// times its size must land on exactly those.
|
||||||
|
func TestMetrics_MethodSeriesCountIsFlatUnderAFlood(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
path := methodProbePath()
|
||||||
|
|
||||||
|
driveMethods(t, h, path, inventedMethods(metricsProbeMethods))
|
||||||
|
seededSeries := seriesCount(gatherMetrics(t, reg))
|
||||||
|
seededLines := scrapeLines(t, reg)
|
||||||
|
|
||||||
|
driveMethods(t, h, path, inventedMethods(metricsProbeMethods*4))
|
||||||
|
floodedSeries := seriesCount(gatherMetrics(t, reg))
|
||||||
|
floodedLines := scrapeLines(t, reg)
|
||||||
|
|
||||||
|
t.Logf(
|
||||||
|
"after %d invented methods: %d series, %d lines; "+
|
||||||
|
"after %d more: %d series, %d lines",
|
||||||
|
metricsProbeMethods, seededSeries, seededLines,
|
||||||
|
metricsProbeMethods*4, floodedSeries, floodedLines,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, seededSeries, floodedSeries,
|
||||||
|
"a flood of invented methods must not mint series",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, seededLines, floodedLines,
|
||||||
|
"a flood of invented methods must not grow the scrape",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_RealMethodsStayDistinct is the positive control. The
|
||||||
|
// bound is worth nothing if it is bought by flattening the metric:
|
||||||
|
// every method the router can route must still carry a series of its
|
||||||
|
// own, one sample each, under the route pattern it was sent to.
|
||||||
|
func TestMetrics_RealMethodsStayDistinct(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
methods := realMethods()
|
||||||
|
|
||||||
|
codes := driveMethods(t, h, methodProbePath(), methods)
|
||||||
|
require.Equal(
|
||||||
|
t, len(methods), codes[http.StatusNotFound],
|
||||||
|
"every real method should have reached the receiver",
|
||||||
|
)
|
||||||
|
|
||||||
|
families := gatherMetrics(t, reg)
|
||||||
|
|
||||||
|
want := make(map[string]struct{}, len(methods))
|
||||||
|
for _, m := range methods {
|
||||||
|
want[m] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, want, methodLabels(families),
|
||||||
|
"real methods must remain distinguishable",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Appearing somewhere in the scrape is not enough: each method
|
||||||
|
// must own its duration series, holding the one sample it sent.
|
||||||
|
observed := 0
|
||||||
|
|
||||||
|
for _, fam := range families {
|
||||||
|
if !strings.HasSuffix(fam.GetName(), "request_duration_seconds") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range fam.GetMetric() {
|
||||||
|
observed++
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, receiverRoutePattern,
|
||||||
|
labelValue(m, "handler"),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, uint64(1),
|
||||||
|
m.GetHistogram().GetSampleCount(),
|
||||||
|
"method %q shares a series",
|
||||||
|
labelValue(m, methodLabel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, len(methods), observed,
|
||||||
|
"one duration series per routable method",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// metricsProbePaths is how many distinct receiver paths each
|
||||||
|
// cardinality assertion drives. The defect these tests pin cost
|
||||||
|
// roughly 26 permanent series per distinct path, so a couple of
|
||||||
|
// hundred puts a regression thousands of series over the bound
|
||||||
|
// rather than leaving it to a rounding argument.
|
||||||
|
metricsProbePaths = 250
|
||||||
|
|
||||||
|
// receiverRoutePattern is the one handler label every receiver
|
||||||
|
// request must produce, however the client varies the path.
|
||||||
|
receiverRoutePattern = "/webhook/{uuid}"
|
||||||
|
|
||||||
|
// okRoute is a static route used to pin that the response-writer
|
||||||
|
// interceptor still reports status and size after the handler id
|
||||||
|
// stopped coming from the URL.
|
||||||
|
okRoute = "/ok"
|
||||||
|
|
||||||
|
// okBody is what okRoute writes, so the recorded response size is
|
||||||
|
// a number the test knows.
|
||||||
|
okBody = "ok"
|
||||||
|
|
||||||
|
// generousReceiverLimit is a per-entrypoint receiver limit high
|
||||||
|
// enough that no probe in this file trips the limiter unless it
|
||||||
|
// means to.
|
||||||
|
generousReceiverLimit = 100000
|
||||||
|
|
||||||
|
// tightReceiverLimit forces the receiver's aggregate limiter to
|
||||||
|
// reject: the aggregate ceiling is ten times this, so a probe of
|
||||||
|
// metricsProbePaths requests spends it many times over.
|
||||||
|
tightReceiverLimit = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// metricsTestRouter builds a router whose middleware ordering mirrors
|
||||||
|
// the real server's: the metrics recorder is GLOBAL, installed by
|
||||||
|
// Server.setupGlobalMiddleware before chi has matched anything, and
|
||||||
|
// the receiver rate limiter is ROUTE-LEVEL, installed by
|
||||||
|
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
||||||
|
// defect, so a test that flattens it would prove nothing.
|
||||||
|
//
|
||||||
|
// The recorder writes to a registry of the test's own rather than the
|
||||||
|
// process-wide default one, so each test observes only its own
|
||||||
|
// traffic.
|
||||||
|
func metricsTestRouter(
|
||||||
|
t *testing.T,
|
||||||
|
receiverLimit int,
|
||||||
|
) (http.Handler, *prometheus.Registry) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Environment: "prod",
|
||||||
|
ReceiverRateLimit: receiverLimit,
|
||||||
|
}
|
||||||
|
m := middleware.NewForTest(
|
||||||
|
log, cfg, newTestSessionManager(cfg, log, nil),
|
||||||
|
)
|
||||||
|
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
rec := prommetrics.NewRecorder(prommetrics.Config{Registry: reg})
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.MetricsMiddlewareForTest(rec))
|
||||||
|
|
||||||
|
r.Get(okRoute, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(okBody))
|
||||||
|
})
|
||||||
|
|
||||||
|
// The real receiver answers 404 for a UUID naming no stored
|
||||||
|
// entrypoint, which is what every invented path here is.
|
||||||
|
r.With(m.ReceiverRateLimit()).HandleFunc(
|
||||||
|
receiverRoutePattern,
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return r, reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe is one request a cardinality assertion sends. Both label
|
||||||
|
// dimensions that have leaked are request-controlled — the path and
|
||||||
|
// the method — so both vary here and one driver sends them.
|
||||||
|
type probe struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// drive sends every probe and returns how many responses carried each
|
||||||
|
// status code.
|
||||||
|
func drive(t *testing.T, h http.Handler, probes []probe) map[int]int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
codes := make(map[int]int)
|
||||||
|
|
||||||
|
for _, p := range probes {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), p.method, p.path, nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
codes[w.Code]++
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// drivePaths sends one POST per supplied path.
|
||||||
|
func drivePaths(
|
||||||
|
t *testing.T,
|
||||||
|
h http.Handler,
|
||||||
|
paths []string,
|
||||||
|
) map[int]int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
probes := make([]probe, 0, len(paths))
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
probes = append(
|
||||||
|
probes, probe{method: http.MethodPost, path: p},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return drive(t, h, probes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
||||||
|
// fresh UUID exactly as an unauthenticated flood would.
|
||||||
|
func receiverPaths(n int) []string {
|
||||||
|
paths := make([]string, 0, n)
|
||||||
|
|
||||||
|
for range n {
|
||||||
|
paths = append(paths, "/webhook/"+uuid.NewString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherMetrics returns the registry's current families, failing the
|
||||||
|
// test if gathering does.
|
||||||
|
func gatherMetrics(
|
||||||
|
t *testing.T,
|
||||||
|
reg *prometheus.Registry,
|
||||||
|
) []*dto.MetricFamily {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
families, err := reg.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return families
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelValue returns the named label from a gathered metric.
|
||||||
|
func labelValue(m *dto.Metric, name string) string {
|
||||||
|
for _, pair := range m.GetLabel() {
|
||||||
|
if pair.GetName() == name {
|
||||||
|
return pair.GetValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerLabels returns the set of distinct `handler` label values
|
||||||
|
// across every gathered series.
|
||||||
|
func handlerLabels(families []*dto.MetricFamily) map[string]struct{} {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, fam := range families {
|
||||||
|
for _, m := range fam.GetMetric() {
|
||||||
|
seen[labelValue(m, "handler")] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesCount is the number of distinct label sets held across every
|
||||||
|
// family: the quantity that grew without bound and was never
|
||||||
|
// reclaimed.
|
||||||
|
func seriesCount(families []*dto.MetricFamily) int {
|
||||||
|
total := 0
|
||||||
|
|
||||||
|
for _, fam := range families {
|
||||||
|
total += len(fam.GetMetric())
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys returns the members of a set, for assertion messages.
|
||||||
|
func keys(set map[string]struct{}) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||||
|
// assertion the issue asks for: N requests to N distinct
|
||||||
|
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
||||||
|
// route pattern. Before the fix this produced N of them.
|
||||||
|
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
paths := receiverPaths(metricsProbePaths)
|
||||||
|
codes := drivePaths(t, h, paths)
|
||||||
|
require.Equal(
|
||||||
|
t, metricsProbePaths, codes[http.StatusNotFound],
|
||||||
|
"every invented UUID should have reached the receiver",
|
||||||
|
)
|
||||||
|
|
||||||
|
families := gatherMetrics(t, reg)
|
||||||
|
labels := handlerLabels(families)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]struct{}{
|
||||||
|
receiverRoutePattern: {},
|
||||||
|
middleware.InflightHandlerConst: {},
|
||||||
|
},
|
||||||
|
labels,
|
||||||
|
"receiver traffic must collapse onto the route pattern",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The scrape must not republish the UUIDs it was driven with.
|
||||||
|
// They are the receiver's only credential.
|
||||||
|
for _, p := range paths {
|
||||||
|
id := strings.TrimPrefix(p, "/webhook/")
|
||||||
|
for label := range labels {
|
||||||
|
assert.NotContains(
|
||||||
|
t, label, id,
|
||||||
|
"an entrypoint UUID reached a metrics label",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_SeriesCountIsFlatUnderAFlood pins the property the
|
||||||
|
// issue measured against a live instance: driving thousands more
|
||||||
|
// distinct paths must not add series. The first batch establishes
|
||||||
|
// every label set the route can produce; the second must land on
|
||||||
|
// exactly those.
|
||||||
|
func TestMetrics_SeriesCountIsFlatUnderAFlood(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
drivePaths(t, h, receiverPaths(metricsProbePaths))
|
||||||
|
before := seriesCount(gatherMetrics(t, reg))
|
||||||
|
|
||||||
|
drivePaths(t, h, receiverPaths(metricsProbePaths*4))
|
||||||
|
after := seriesCount(gatherMetrics(t, reg))
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, before, after,
|
||||||
|
"a flood of distinct paths must not mint series",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_RateLimitedRequestsCarryTheRoutePattern covers the
|
||||||
|
// majority case: most of the leaked series were 429s. Those requests
|
||||||
|
// never reach a handler, so they take a different path through the
|
||||||
|
// stack — but chi has already matched the route by the time the
|
||||||
|
// route-level limiter rejects them, and the recording happens after
|
||||||
|
// the whole chain returns, so they must land on the pattern too.
|
||||||
|
func TestMetrics_RateLimitedRequestsCarryTheRoutePattern(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, tightReceiverLimit)
|
||||||
|
|
||||||
|
codes := drivePaths(t, h, receiverPaths(metricsProbePaths))
|
||||||
|
require.Positive(
|
||||||
|
t, codes[http.StatusTooManyRequests],
|
||||||
|
"the probe must actually exhaust the aggregate limiter",
|
||||||
|
)
|
||||||
|
|
||||||
|
families := gatherMetrics(t, reg)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]struct{}{
|
||||||
|
receiverRoutePattern: {},
|
||||||
|
middleware.InflightHandlerConst: {},
|
||||||
|
},
|
||||||
|
handlerLabels(families),
|
||||||
|
"rejected requests must collapse onto the route pattern",
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected := 0
|
||||||
|
|
||||||
|
for _, fam := range families {
|
||||||
|
for _, m := range fam.GetMetric() {
|
||||||
|
if labelValue(m, "code") != "429" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected++
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, receiverRoutePattern,
|
||||||
|
labelValue(m, "handler"),
|
||||||
|
"a 429 series carried a non-pattern handler",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Positive(
|
||||||
|
t, rejected, "no 429 series was recorded at all",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_UnmatchedPathsCollapseToTheSentinel decides and pins the
|
||||||
|
// unmatched-route case. A path matching no route has no pattern, so
|
||||||
|
// it carries the same fixed sentinel the access log uses. Without
|
||||||
|
// that, an unmatched flood leaks exactly as the receiver did.
|
||||||
|
func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
paths := make([]string, 0, metricsProbePaths)
|
||||||
|
|
||||||
|
for i := range metricsProbePaths {
|
||||||
|
id := uuid.NewString()
|
||||||
|
|
||||||
|
// Two shapes: one matching no prefix at all, and one under
|
||||||
|
// the receiver prefix but with a segment count the pattern
|
||||||
|
// cannot match.
|
||||||
|
if i%2 == 0 {
|
||||||
|
paths = append(paths, "/"+id)
|
||||||
|
} else {
|
||||||
|
paths = append(paths, "/webhook/"+id+"/"+id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := drivePaths(t, h, paths)
|
||||||
|
require.Equal(
|
||||||
|
t, metricsProbePaths, codes[http.StatusNotFound],
|
||||||
|
"every probe path should have gone unmatched",
|
||||||
|
)
|
||||||
|
|
||||||
|
labels := handlerLabels(gatherMetrics(t, reg))
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]struct{}{
|
||||||
|
middleware.UnmatchedRouteConst: {},
|
||||||
|
middleware.InflightHandlerConst: {},
|
||||||
|
},
|
||||||
|
labels,
|
||||||
|
"unmatched paths must collapse onto one sentinel, got %v",
|
||||||
|
keys(labels),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_InflightGaugeIsAggregateAndBalanced pins the one metric
|
||||||
|
// that cannot carry a pattern. It is incremented before routing and
|
||||||
|
// decremented after, so it gets a fixed label -- and the two calls
|
||||||
|
// must therefore agree, leaving the gauge at zero once the traffic
|
||||||
|
// has drained rather than stuck above it.
|
||||||
|
func TestMetrics_InflightGaugeIsAggregateAndBalanced(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
drivePaths(t, h, receiverPaths(metricsProbePaths))
|
||||||
|
|
||||||
|
var inflight []*dto.Metric
|
||||||
|
|
||||||
|
for _, fam := range gatherMetrics(t, reg) {
|
||||||
|
if strings.HasSuffix(fam.GetName(), "requests_inflight") {
|
||||||
|
inflight = fam.GetMetric()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(
|
||||||
|
t, inflight, 1,
|
||||||
|
"the inflight gauge must hold exactly one series",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, middleware.InflightHandlerConst,
|
||||||
|
labelValue(inflight[0], "handler"),
|
||||||
|
)
|
||||||
|
assert.InDelta(
|
||||||
|
t, 0.0, inflight[0].GetGauge().GetValue(), 0.0,
|
||||||
|
"the gauge must balance back to zero",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_StatusAndSizeStillRecorded guards the response-writer
|
||||||
|
// interceptor the recording middleware wraps around every request.
|
||||||
|
// The handler label changed; what the interceptor reports must not
|
||||||
|
// have.
|
||||||
|
func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, reg := metricsTestRouter(t, generousReceiverLimit)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, okRoute, nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
require.Equal(t, okBody, w.Body.String())
|
||||||
|
|
||||||
|
var size *dto.Metric
|
||||||
|
|
||||||
|
for _, fam := range gatherMetrics(t, reg) {
|
||||||
|
if !strings.HasSuffix(fam.GetName(), "response_size_bytes") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range fam.GetMetric() {
|
||||||
|
if labelValue(m, "handler") == okRoute {
|
||||||
|
size = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(
|
||||||
|
t, size, "no response size series for the static route",
|
||||||
|
)
|
||||||
|
assert.Equal(t, "200", labelValue(size, "code"))
|
||||||
|
assert.Equal(t, uint64(1), size.GetHistogram().GetSampleCount())
|
||||||
|
assert.InDelta(
|
||||||
|
t, float64(len(okBody)),
|
||||||
|
size.GetHistogram().GetSampleSum(), 0.0,
|
||||||
|
"the interceptor must still count written bytes",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,9 +13,6 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -29,10 +26,15 @@ const (
|
|||||||
// preflight response can be cached.
|
// preflight response can be cached.
|
||||||
corsMaxAge = 300
|
corsMaxAge = 300
|
||||||
|
|
||||||
// unmatchedRoute is logged in the access log's url field when a
|
// unmatchedRoute stands in for a request that matched no route
|
||||||
// redirected or rejected request matched no route pattern at
|
// pattern at all. Every byte of such a path is client-chosen, so
|
||||||
// all. Every byte of such a path is client-chosen, so none of it
|
// none of it is kept.
|
||||||
// is logged.
|
//
|
||||||
|
// It is the access log's url field on a redirected or rejected
|
||||||
|
// request, and it is the metrics `handler` label on the same
|
||||||
|
// request; see metrics.go. Both surfaces are written once per
|
||||||
|
// request from a path the client picks, so both have to collapse
|
||||||
|
// the unmatched case into one fixed value.
|
||||||
unmatchedRoute = "(unmatched)"
|
unmatchedRoute = "(unmatched)"
|
||||||
|
|
||||||
// redactedQuery stands in for the query string on the access log
|
// redactedQuery stands in for the query string on the access log
|
||||||
@@ -438,17 +440,6 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records Prometheus HTTP metrics.
|
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
|
||||||
mdlw := ghmm.New(ghmm.Config{
|
|
||||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return std.Handler("", mdlw, next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsAuth returns middleware that protects metrics endpoints
|
// MetricsAuth returns middleware that protects metrics endpoints
|
||||||
// with basic auth.
|
// with basic auth.
|
||||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ const (
|
|||||||
// replayRateInterval is the time window for the replay limit.
|
// replayRateInterval is the time window for the replay limit.
|
||||||
replayRateInterval = 1 * time.Minute
|
replayRateInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// resubmitRateLimit is the maximum number of event resubmits one
|
||||||
|
// client may queue per interval. A resubmit stores an event and
|
||||||
|
// queues one delivery per active target, so it costs more
|
||||||
|
// outbound work per press than a replay does. Firing a captured
|
||||||
|
// event repeatedly at a backend under development is the point of
|
||||||
|
// the action, so the ceiling stays well above the rate a person
|
||||||
|
// iterates at.
|
||||||
|
resubmitRateLimit = 30
|
||||||
|
|
||||||
|
// resubmitRateInterval is the time window for the resubmit limit.
|
||||||
|
resubmitRateInterval = 1 * time.Minute
|
||||||
|
|
||||||
// receiverRateInterval is the time window for the webhook
|
// receiverRateInterval is the time window for the webhook
|
||||||
// receiver rate limit. The configured limit is expressed in
|
// receiver rate limit. The configured limit is expressed in
|
||||||
// requests per minute.
|
// requests per minute.
|
||||||
@@ -315,6 +327,22 @@ func (m *Middleware) ReplayRateLimit() func(http.Handler) http.Handler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResubmitRateLimit returns middleware that enforces per-IP rate
|
||||||
|
// limiting on event resubmits.
|
||||||
|
//
|
||||||
|
// It is a separate bucket from the replay limit so that exhausting one
|
||||||
|
// does not take the other away: replay is a recovery action and
|
||||||
|
// resubmit is a testing action, and an operator iterating on a backend
|
||||||
|
// must not lose the ability to re-send a failed delivery.
|
||||||
|
func (m *Middleware) ResubmitRateLimit() func(http.Handler) http.Handler {
|
||||||
|
return m.postRateLimit(
|
||||||
|
resubmitRateLimit,
|
||||||
|
resubmitRateInterval,
|
||||||
|
"event resubmit rate limit exceeded",
|
||||||
|
"Too many resubmits. Please try again later.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// postRateLimit builds middleware that enforces a per-IP rate
|
// postRateLimit builds middleware that enforces a per-IP rate
|
||||||
// limit on POST requests only; all other methods pass through
|
// limit on POST requests only; all other methods pass through
|
||||||
// unaffected. Requests over the limit receive a 429 with the
|
// unaffected. Requests over the limit receive a 429 with the
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// what the access log records and the metrics count, and outside the
|
// what the access log records and the metrics count, and outside the
|
||||||
// sentryhttp handler, whose Repanic option depends on something
|
// sentryhttp handler, whose Repanic option depends on something
|
||||||
// further out recovering what it re-raises.
|
// further out recovering what it re-raises.
|
||||||
|
//
|
||||||
|
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||||
|
// set before panicking, because a request that failed must not hand
|
||||||
|
// the client a credential; every other header is left to http.Error.
|
||||||
|
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
@@ -164,6 +169,8 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw.Header().Del("Set-Cookie")
|
||||||
|
|
||||||
http.Error(
|
http.Error(
|
||||||
rw,
|
rw,
|
||||||
http.StatusText(
|
http.StatusText(
|
||||||
|
|||||||
@@ -304,16 +304,44 @@ func TestRecovererRepanicsErrAbortHandler(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecovererDropsSetCookieFromTheRecovered500 covers a handler that
|
||||||
|
// sets a cookie and a redirect target and then panics before sending
|
||||||
|
// anything. A request that failed must not hand the client a
|
||||||
|
// credential, so the 500 carries no cookie; Location is left alone.
|
||||||
|
func TestRecovererDropsSetCookieFromTheRecovered500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
probe := newRecovererProbe(
|
||||||
|
t, false,
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Set-Cookie", "session=x")
|
||||||
|
w.Header().Set("Location", "/after")
|
||||||
|
|
||||||
|
panic(panicMarker)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := probe.get(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||||
|
assert.Empty(t, resp.Cookies())
|
||||||
|
assert.Equal(t, "/after", resp.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
// TestRecovererKeepsAnAlreadyCommittedResponse covers a handler that
|
// TestRecovererKeepsAnAlreadyCommittedResponse covers a handler that
|
||||||
// panics after sending its status. The bytes are already on the wire,
|
// panics after sending its status. The bytes are already on the wire,
|
||||||
// so a second WriteHeader would change nothing the client sees and
|
// cookie included, so a second WriteHeader would change nothing the
|
||||||
// would draw net/http's "superfluous response.WriteHeader" report.
|
// client sees and would draw net/http's "superfluous
|
||||||
|
// response.WriteHeader" report.
|
||||||
func TestRecovererKeepsAnAlreadyCommittedResponse(t *testing.T) {
|
func TestRecovererKeepsAnAlreadyCommittedResponse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
probe := newRecovererProbe(
|
probe := newRecovererProbe(
|
||||||
t, false,
|
t, false,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Set-Cookie", "session=x")
|
||||||
w.WriteHeader(committedStatus)
|
w.WriteHeader(committedStatus)
|
||||||
_, _ = w.Write([]byte("partial"))
|
_, _ = w.Write([]byte("partial"))
|
||||||
|
|
||||||
@@ -331,6 +359,7 @@ func TestRecovererKeepsAnAlreadyCommittedResponse(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, committedStatus, resp.StatusCode)
|
assert.Equal(t, committedStatus, resp.StatusCode)
|
||||||
assert.Equal(t, "partial", string(body))
|
assert.Equal(t, "partial", string(body))
|
||||||
|
assert.Len(t, resp.Cookies(), 1)
|
||||||
|
|
||||||
record := probe.panicRecord(t)
|
record := probe.panicRecord(t)
|
||||||
assert.Equal(t, panicMarker, record["panic"])
|
assert.Equal(t, panicMarker, record["panic"])
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package reqtls answers one question, in one place, for the whole
|
||||||
|
// application: did this request reach the service over TLS?
|
||||||
|
//
|
||||||
|
// It exists because that question used to be answered independently in
|
||||||
|
// several packages, by hand, and the answers disagreed. The session
|
||||||
|
// cookie's Secure attribute was decided at startup from the configured
|
||||||
|
// environment while the CSRF cookie's was decided per-request, so a
|
||||||
|
// deployment behind a TLS proxy in the dev environment emitted one
|
||||||
|
// Secure cookie and one non-Secure cookie on the same response.
|
||||||
|
// Everything kept working, which is exactly why nobody noticed.
|
||||||
|
//
|
||||||
|
// Any code that needs a scheme or a Secure flag must call IsTLS rather
|
||||||
|
// than reading the request itself.
|
||||||
|
package reqtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forwardedProtoHeader is the de-facto standard header by which a
|
||||||
|
// TLS-terminating reverse proxy reports the protocol the CLIENT used.
|
||||||
|
const forwardedProtoHeader = "X-Forwarded-Proto"
|
||||||
|
|
||||||
|
// IsTLS reports whether the client-facing connection uses TLS: either
|
||||||
|
// the request arrived over TLS directly, or a reverse proxy terminated
|
||||||
|
// TLS and said so in X-Forwarded-Proto.
|
||||||
|
//
|
||||||
|
// The header is only as trustworthy as whatever sits in front of the
|
||||||
|
// listener. A proxy that overwrites it -- which is what the deployment
|
||||||
|
// documentation requires -- makes it authoritative; a listener exposed
|
||||||
|
// directly to clients lets any client assert it. That is the same
|
||||||
|
// exposure every X-Forwarded-* consumer carries.
|
||||||
|
func IsTLS(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || forwardedProto(r) == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedProto reduces X-Forwarded-Proto to a bare, comparable
|
||||||
|
// protocol token, or "" when the header is absent or blank.
|
||||||
|
//
|
||||||
|
// Two shapes that real infrastructure emits do not survive an exact
|
||||||
|
// comparison against "https", and both name a TLS client connection:
|
||||||
|
//
|
||||||
|
// - "HTTPS", because the header value is a case-insensitive token and
|
||||||
|
// nothing obliges a proxy to emit it lowercased.
|
||||||
|
// - "https, http", because a proxy chained behind another proxy
|
||||||
|
// APPENDS its own hop instead of replacing the value. As with
|
||||||
|
// X-Forwarded-For, the leftmost element is the one nearest the
|
||||||
|
// client, so it is the element that describes the browser's
|
||||||
|
// connection -- the only hop a cookie's Secure attribute is about.
|
||||||
|
//
|
||||||
|
// Landing on the plaintext path for either of those spellings is not a
|
||||||
|
// cosmetic error: it stops gorilla/csrf enforcing the strict Referer
|
||||||
|
// check on a site that genuinely is HTTPS.
|
||||||
|
func forwardedProto(r *http.Request) string {
|
||||||
|
first, _, _ := strings.Cut(r.Header.Get(forwardedProtoHeader), ",")
|
||||||
|
|
||||||
|
return strings.ToLower(strings.TrimSpace(first))
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package reqtls_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newReq builds a plaintext request with no forwarding headers.
|
||||||
|
func newReq(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTLS_DirectTLS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newReq(t)
|
||||||
|
r.TLS = &tls.ConnectionState{}
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, reqtls.IsTLS(r),
|
||||||
|
"a request that arrived over TLS is TLS",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTLS_PlaintextNoHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(
|
||||||
|
t, reqtls.IsTLS(newReq(t)),
|
||||||
|
"no TLS connection and no header means plaintext",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoCase is one X-Forwarded-Proto spelling and the answer IsTLS
|
||||||
|
// owes it.
|
||||||
|
type protoCase struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoCases enumerates the header values real infrastructure emits.
|
||||||
|
func protoCases() []protoCase {
|
||||||
|
return append(protoTLSCases(), protoPlaintextCases()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoTLSCases are the spellings that name a TLS client connection.
|
||||||
|
// Every one but the first is a spelling an exact == "https"
|
||||||
|
// comparison used to miss, silently downgrading a genuinely-HTTPS
|
||||||
|
// deployment to the plaintext path.
|
||||||
|
func protoTLSCases() []protoCase {
|
||||||
|
return []protoCase{
|
||||||
|
{
|
||||||
|
name: "lowercase",
|
||||||
|
header: "https",
|
||||||
|
want: true,
|
||||||
|
why: "the ordinary spelling",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase",
|
||||||
|
header: "HTTPS",
|
||||||
|
want: true,
|
||||||
|
why: "the value is a case-insensitive token; " +
|
||||||
|
"nothing obliges a proxy to lowercase it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case",
|
||||||
|
header: "HttpS",
|
||||||
|
want: true,
|
||||||
|
why: "case folding must be total, not just the two extremes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with plaintext inner hop",
|
||||||
|
header: "https, http",
|
||||||
|
want: true,
|
||||||
|
why: "a chained proxy appends its hop; the leftmost " +
|
||||||
|
"element is the client-facing one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain of two TLS hops",
|
||||||
|
header: "https,https",
|
||||||
|
want: true,
|
||||||
|
why: "appended chain with no space after the comma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing space",
|
||||||
|
header: "https ",
|
||||||
|
want: true,
|
||||||
|
why: "surrounding whitespace is not part of the token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading space",
|
||||||
|
header: " https",
|
||||||
|
want: true,
|
||||||
|
why: "surrounding whitespace is not part of the token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase chain",
|
||||||
|
header: "HTTPS, HTTP",
|
||||||
|
want: true,
|
||||||
|
why: "case folding and chain splitting must compose",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoPlaintextCases are the values that must NOT be read as TLS.
|
||||||
|
func protoPlaintextCases() []protoCase {
|
||||||
|
return []protoCase{
|
||||||
|
{
|
||||||
|
name: "plaintext",
|
||||||
|
header: "http",
|
||||||
|
want: false,
|
||||||
|
why: "the negative control: the proxy reports plaintext",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plaintext chain with TLS inner hop",
|
||||||
|
header: "http, https",
|
||||||
|
want: false,
|
||||||
|
why: "the client-facing hop is plaintext even though " +
|
||||||
|
"an inner hop used TLS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
header: "",
|
||||||
|
want: false,
|
||||||
|
why: "an empty header asserts nothing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
header: " ",
|
||||||
|
want: false,
|
||||||
|
why: "a blank header asserts nothing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated token",
|
||||||
|
header: "ftp",
|
||||||
|
want: false,
|
||||||
|
why: "only https means TLS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https as a substring",
|
||||||
|
header: "nothttps",
|
||||||
|
want: false,
|
||||||
|
why: "matching must be on the whole token, not a substring",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTLS_ForwardedProtoSpellings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range protoCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newReq(t)
|
||||||
|
r.Header.Set("X-Forwarded-Proto", tc.header)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, tc.want, reqtls.IsTLS(r),
|
||||||
|
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTLS_DirectTLSBeatsPlaintextHeader pins the precedence: a
|
||||||
|
// connection this process itself terminated with TLS is a fact, and a
|
||||||
|
// header claiming otherwise does not override it.
|
||||||
|
func TestIsTLS_DirectTLSBeatsPlaintextHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newReq(t)
|
||||||
|
r.TLS = &tls.ConnectionState{}
|
||||||
|
r.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, reqtls.IsTLS(r),
|
||||||
|
"an actual TLS connection outranks a header claiming plaintext",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTLS_FirstHeaderValueWins covers a proxy that adds a second
|
||||||
|
// header line rather than appending to the existing one. net/http
|
||||||
|
// keeps them as separate values; the first is the client-facing hop,
|
||||||
|
// matching how the comma-separated form is read.
|
||||||
|
func TestIsTLS_FirstHeaderValueWins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newReq(t)
|
||||||
|
r.Header.Add("X-Forwarded-Proto", "https")
|
||||||
|
r.Header.Add("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, reqtls.IsTLS(r),
|
||||||
|
"the first header line is the client-facing hop",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -164,6 +164,7 @@ func newServerApp(
|
|||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||||
middleware.New,
|
middleware.New,
|
||||||
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
),
|
),
|
||||||
fx.Populate(&h),
|
fx.Populate(&h),
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// loopbackV4 is the shipped BIND_ADDRESS default.
|
||||||
|
loopbackV4 = "127.0.0.1"
|
||||||
|
|
||||||
|
// wildcardV4 is the value a container deployment must set,
|
||||||
|
// where a loopback-bound process is unreachable from outside
|
||||||
|
// its network namespace even with a published port.
|
||||||
|
wildcardV4 = "0.0.0.0"
|
||||||
|
|
||||||
|
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
||||||
|
// well-formed literal that no host is assigned, so binding it
|
||||||
|
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
||||||
|
// unexpected.
|
||||||
|
unavailableAddr = "192.0.2.1"
|
||||||
|
|
||||||
|
// listenReadyTimeout bounds the wait for the listener to accept
|
||||||
|
// connections. The bind itself is immediate; this only covers
|
||||||
|
// goroutine scheduling.
|
||||||
|
listenReadyTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// listenPollInterval is how often the readiness wait retries.
|
||||||
|
listenPollInterval = 10 * time.Millisecond
|
||||||
|
|
||||||
|
// dialTimeout bounds a single connection attempt in these
|
||||||
|
// tests. Everything dialled here is on this host, so a dial
|
||||||
|
// that is not answered immediately is a failure, not slowness.
|
||||||
|
dialTimeout = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// freePort returns a TCP port that is free on every local address at
|
||||||
|
// the moment it returns, by taking one on the wildcard and releasing
|
||||||
|
// it. The window between release and re-bind is the standard one
|
||||||
|
// every "pick a free port" helper carries.
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var listenCfg net.ListenConfig
|
||||||
|
|
||||||
|
l, err := listenCfg.Listen(t.Context(), "tcp", "0.0.0.0: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
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherLocalAddr returns a local IPv4 address that is not
|
||||||
|
// loopbackV4, or skips the test when the host has none.
|
||||||
|
//
|
||||||
|
// The bind-address tests need a second address of this host to stand
|
||||||
|
// in for "another interface": what a wildcard bind claims and a
|
||||||
|
// loopback bind does not. 127.0.0.2 is that address on Linux, where
|
||||||
|
// the whole 127.0.0.0/8 is local; elsewhere an interface address is
|
||||||
|
// used instead. Each candidate is proven bindable before it is
|
||||||
|
// returned, so a host that offers neither skips rather than fails on
|
||||||
|
// something that was never about the code under test.
|
||||||
|
func otherLocalAddr(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
candidates := []string{"127.0.0.2"}
|
||||||
|
|
||||||
|
ifaceAddrs, err := net.InterfaceAddrs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, a := range ifaceAddrs {
|
||||||
|
ipNet, ok := a.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ip4 := ipNet.IP.To4()
|
||||||
|
if ip4 == nil || ip4.String() == loopbackV4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = append(candidates, ip4.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var listenCfg net.ListenConfig
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
l, listenErr := listenCfg.Listen(
|
||||||
|
t.Context(), "tcp", net.JoinHostPort(candidate, "0"),
|
||||||
|
)
|
||||||
|
if listenErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, l.Close())
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Skip("host has no second local IPv4 address to bind")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBoundServer starts the wired app with the given bind address
|
||||||
|
// on a free port and returns that port. The app is stopped on
|
||||||
|
// cleanup.
|
||||||
|
func startBoundServer(t *testing.T, bindAddress string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
port := freePort(t)
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.cfg.BindAddress = bindAddress
|
||||||
|
env.cfg.Port = port
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Supply(env.log, env.cfg, env.mw, env.hnd),
|
||||||
|
fx.Provide(globals.New, server.New),
|
||||||
|
fx.Invoke(func(*server.Server) {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
startCtx, cancelStart := context.WithTimeout(
|
||||||
|
context.Background(), lifecycleTimeout,
|
||||||
|
)
|
||||||
|
defer cancelStart()
|
||||||
|
|
||||||
|
require.NoError(t, app.Start(startCtx))
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stopCtx, cancelStop := context.WithTimeout(
|
||||||
|
context.Background(), lifecycleTimeout,
|
||||||
|
)
|
||||||
|
defer cancelStop()
|
||||||
|
|
||||||
|
require.NoError(t, app.Stop(stopCtx))
|
||||||
|
})
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialable reports whether a TCP connection to addr succeeds.
|
||||||
|
func dialable(ctx context.Context, addr string) bool {
|
||||||
|
dialer := net.Dialer{Timeout: dialTimeout}
|
||||||
|
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireDialable waits for addr to accept connections, failing the
|
||||||
|
// test if it never does.
|
||||||
|
func requireDialable(t *testing.T, addr string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(listenReadyTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if dialable(t.Context(), addr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(listenPollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("nothing accepted connections on %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListenAddr pins how BindAddress and Port are rendered into the
|
||||||
|
// listen address.
|
||||||
|
//
|
||||||
|
// The defect this covers was a bare fmt.Sprintf(":%d", port), which
|
||||||
|
// binds every interface with no way to say otherwise. The IPv6 rows
|
||||||
|
// are here because an unbracketed IPv6 host would produce an address
|
||||||
|
// net.Listen rejects, turning a valid configuration into a startup
|
||||||
|
// failure.
|
||||||
|
func TestListenAddr(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bindAddress string
|
||||||
|
port int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "loopback default",
|
||||||
|
bindAddress: loopbackV4,
|
||||||
|
port: 8080,
|
||||||
|
expected: "127.0.0.1:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv4 wildcard",
|
||||||
|
bindAddress: wildcardV4,
|
||||||
|
port: 8080,
|
||||||
|
expected: "0.0.0.0:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 wildcard is bracketed",
|
||||||
|
bindAddress: "::",
|
||||||
|
port: 8080,
|
||||||
|
expected: "[::]:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 literal is bracketed",
|
||||||
|
bindAddress: "2001:db8::5",
|
||||||
|
port: 9001,
|
||||||
|
expected: "[2001:db8::5]:9001",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, server.ListenAddrForTest(
|
||||||
|
&config.Config{
|
||||||
|
BindAddress: tt.bindAddress,
|
||||||
|
Port: tt.port,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_LoopbackIsNotOnOtherAddresses proves the fix end to
|
||||||
|
// end: with BIND_ADDRESS at its loopback default, the cleartext
|
||||||
|
// listener answers on loopback and has not claimed any other address
|
||||||
|
// of this host.
|
||||||
|
//
|
||||||
|
// The second address is proven free by binding it on the same port
|
||||||
|
// while the server runs. That is the assertion that fails against the
|
||||||
|
// old wildcard bind — a wildcard listener owns the port on every
|
||||||
|
// address, so this bind would return EADDRINUSE. Dialling from
|
||||||
|
// another machine is what the operator cares about, and this is the
|
||||||
|
// in-process form of it: the socket the remote host would connect to
|
||||||
|
// does not exist.
|
||||||
|
func TestBindAddress_LoopbackIsNotOnOtherAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
other := otherLocalAddr(t)
|
||||||
|
port := startBoundServer(t, loopbackV4)
|
||||||
|
|
||||||
|
// Positive control: the service really is up and serving.
|
||||||
|
requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)))
|
||||||
|
|
||||||
|
var listenCfg net.ListenConfig
|
||||||
|
|
||||||
|
l, err := listenCfg.Listen(
|
||||||
|
t.Context(), "tcp",
|
||||||
|
net.JoinHostPort(other, strconv.Itoa(port)),
|
||||||
|
)
|
||||||
|
require.NoError(
|
||||||
|
t, err,
|
||||||
|
"port %d on %s is taken while bound to %s: the listener "+
|
||||||
|
"claimed more than its configured address",
|
||||||
|
port, other, loopbackV4,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, l.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_WildcardReachesOtherAddresses is the counterpart:
|
||||||
|
// the value a container deployment sets does reach the addresses the
|
||||||
|
// default withholds. Without this, a loopback-only bind would pass
|
||||||
|
// the test above by never listening at all.
|
||||||
|
func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
other := otherLocalAddr(t)
|
||||||
|
port := startBoundServer(t, wildcardV4)
|
||||||
|
|
||||||
|
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
||||||
|
// listener serves the application rather than merely accepting TCP,
|
||||||
|
// so a bind address that is honoured cannot be mistaken for one that
|
||||||
|
// is honoured and broken.
|
||||||
|
func TestBindAddress_ServesRequestsOnConfiguredAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
port := startBoundServer(t, loopbackV4)
|
||||||
|
addr := net.JoinHostPort(loopbackV4, strconv.Itoa(port))
|
||||||
|
requireDialable(t, addr)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet,
|
||||||
|
"http://"+addr+"/.well-known/healthcheck", nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: dialTimeout}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_UnavailableAddressShutsDownTheApp covers the half
|
||||||
|
// of the fail-loud rule that configuration parsing cannot reach. A
|
||||||
|
// syntactically valid address that is not assigned to this host
|
||||||
|
// parses fine and fails at bind time, after fx has already reported
|
||||||
|
// RUNNING. It must end the process non-zero rather than leave it
|
||||||
|
// alive with nothing listening.
|
||||||
|
func TestBindAddress_UnavailableAddressShutsDownTheApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.cfg.BindAddress = unavailableAddr
|
||||||
|
env.cfg.Port = freePort(t)
|
||||||
|
|
||||||
|
requireListenFailureExit(t, env)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// earlyStopIterations is how many start/stop cycles the race test
|
||||||
|
// runs. The window it aims at is the gap between the OnStart hook
|
||||||
|
// returning and the serving goroutine reaching its first field
|
||||||
|
// access, which is microseconds wide. The race detector reports an
|
||||||
|
// unsynchronised pair whenever it observes one, but it has to observe
|
||||||
|
// one, so a single cycle can miss purely on scheduling. Repetition
|
||||||
|
// makes the observation reliable; the collaborators are built once,
|
||||||
|
// so the cycles themselves are cheap.
|
||||||
|
const earlyStopIterations = 25
|
||||||
|
|
||||||
|
// TestEarlyShutdown_NoPanicAndNoRace stops the application
|
||||||
|
// immediately after starting it, before the serving goroutine has
|
||||||
|
// necessarily run at all.
|
||||||
|
//
|
||||||
|
// Two defects live in that window. The OnStart hook returns as soon
|
||||||
|
// as it has spawned the serving goroutine, so fx runs the stop
|
||||||
|
// sequence against a Server whose serving goroutine may not have
|
||||||
|
// executed a single line. cleanShutdown called Shutdown on an
|
||||||
|
// httpServer that goroutine was supposed to assign, which was a nil
|
||||||
|
// dereference on an early SIGTERM; and it read httpServer and
|
||||||
|
// sentryEnabled with nothing ordering those reads against the
|
||||||
|
// goroutine's writes, which is a data race that only surfaces once
|
||||||
|
// something both starts and stops the server. Nothing did before this
|
||||||
|
// test: the listen-failure test never binds, and the router tests
|
||||||
|
// bypass the lifecycle entirely.
|
||||||
|
//
|
||||||
|
// httpServer is now built in New, on the constructing goroutine, so
|
||||||
|
// it is written before any hook exists and can never be nil.
|
||||||
|
// sentryEnabled is atomic. This test is what catches either one
|
||||||
|
// coming back — under -race, which is how the suite runs.
|
||||||
|
func TestEarlyShutdown_NoPanicAndNoRace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Built once: the collaborators are not what is under test, and
|
||||||
|
// standing up a database per iteration would make repetition too
|
||||||
|
// expensive to be worth having.
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.cfg.BindAddress = loopbackV4
|
||||||
|
|
||||||
|
for range earlyStopIterations {
|
||||||
|
requireStartStopIsClean(t, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireStartStopIsClean runs one start/stop cycle with no wait in
|
||||||
|
// between, failing the test if either half errors.
|
||||||
|
//
|
||||||
|
// Each cycle gets a fresh fx app, so the Server under test is
|
||||||
|
// constructed anew every time — that construction is where the
|
||||||
|
// httpServer write now happens, and reusing one Server would test it
|
||||||
|
// only once.
|
||||||
|
func requireStartStopIsClean(t *testing.T, env *testEnv) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
env.cfg.Port = freePort(t)
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Supply(env.log, env.cfg, env.mw, env.hnd),
|
||||||
|
fx.Provide(globals.New, server.New),
|
||||||
|
fx.Invoke(func(*server.Server) {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
startCtx, cancelStart := context.WithTimeout(
|
||||||
|
context.Background(), lifecycleTimeout,
|
||||||
|
)
|
||||||
|
defer cancelStart()
|
||||||
|
|
||||||
|
require.NoError(t, app.Start(startCtx))
|
||||||
|
|
||||||
|
// No sleep and no readiness wait: stopping while the serving
|
||||||
|
// goroutine is still in flight is the whole point.
|
||||||
|
stopCtx, cancelStop := context.WithTimeout(
|
||||||
|
context.Background(), lifecycleTimeout,
|
||||||
|
)
|
||||||
|
defer cancelStop()
|
||||||
|
|
||||||
|
require.NoError(t, app.Stop(stopCtx))
|
||||||
|
}
|
||||||
@@ -55,6 +55,16 @@ func NewRouterForTest(
|
|||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListenAddrForTest exposes the address the HTTP listener binds for
|
||||||
|
// a given Config, so the rendering of host and port — IPv6
|
||||||
|
// bracketing above all — can be pinned without standing up a
|
||||||
|
// listener.
|
||||||
|
func ListenAddrForTest(cfg *config.Config) string {
|
||||||
|
s := &Server{params: ServerParams{Config: cfg}}
|
||||||
|
|
||||||
|
return s.listenAddr()
|
||||||
|
}
|
||||||
|
|
||||||
// ProbePattern is the route NewRouterWithProbeForTest adds to the
|
// ProbePattern is the route NewRouterWithProbeForTest adds to the
|
||||||
// production route tree.
|
// production route tree.
|
||||||
const ProbePattern = "/probe"
|
const ProbePattern = "/probe"
|
||||||
@@ -80,12 +90,12 @@ func NewRouterWithProbeForTest(
|
|||||||
probe http.HandlerFunc,
|
probe http.HandlerFunc,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
log: log,
|
log: log,
|
||||||
mw: mw,
|
mw: mw,
|
||||||
h: h,
|
h: h,
|
||||||
params: ServerParams{Config: cfg},
|
params: ServerParams{Config: cfg},
|
||||||
sentryEnabled: sentryEnabled,
|
|
||||||
}
|
}
|
||||||
|
s.sentryEnabled.Store(sentryEnabled)
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
s.router.Handle(ProbePattern, probe)
|
s.router.Handle(ProbePattern, probe)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user