1 Commits
Author SHA1 Message Date
sneak 14a38df093 Store a target's number of delivery attempts, at least one (closes #540)
check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts
(max_attempts), and the form field, parser and ceiling are renamed to
match. 1 is a single attempt with no circuit breaker; above 1 retries
behind the breaker up to that many attempts. Both target forms refuse
0 and less; a blank field on the add target form gives 1, and on the
edit form keeps the stored value. The target page shows the plain
number, and the README's two paragraphs on 0 against 1 become one
sentence.

Model: opus-5-5
2026-10-15 11:34:18 +00:00
65 changed files with 1202 additions and 1609 deletions
+24 -14
View File
@@ -76,6 +76,8 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# depends on it. # depends on it.
# #
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
@@ -165,7 +167,9 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
# The image ships git and make, which the version step below uses. # Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image
# ships git and make, which the version step below uses.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Nothing is wanted from the lint and test phases or from the stylesheet and # Nothing is wanted from the lint and test phases or from the stylesheet and
@@ -207,11 +211,26 @@ RUN version="$(make version VERSION="$VERSION")"; \
fi fi
# Builds through the Makefile's build target, which runs script/assets # Builds through the Makefile's build target, which runs script/assets
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds # (Alpine.js, extracted from its tarball in 3p/) first.
# with cgo off, so the binary is statically linked and runs on the Alpine
# runtime image below as it is.
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
# Rebuild with static linking for the Alpine runtime. The cgo binary the
# step above builds is dynamically linked against glibc, which Alpine
# (musl) does not have.
#
# A statically linked glibc still loads shared libraries at run time for
# getaddrinfo and dlopen, and Alpine has none to load, so the build tags
# keep both out of the binary: netgo and osusergo select Go's own DNS
# resolver and user lookup instead of glibc's, and
# sqlite_omit_load_extension leaves out SQLite extension loading, which
# webhooker never uses.
#
# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather
# than a -ldflags of their own: the build target composes them with the
# -X that stamps the version, so this relink cannot silently drop the
# stamp.
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"'
# Runtime stage # Runtime stage
# alpine:3.21, 2026-03-17 # alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
@@ -255,17 +274,8 @@ EXPOSE 8080
# port. # port.
ENV BIND_ADDRESS=0.0.0.0 ENV BIND_ADDRESS=0.0.0.0
# The probe requests the container's own address, not loopback, so a
# listener that answers only on loopback (an empty or loopback
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
# IPv6-only network it prints none, and the probe requests the IPv6
# address /etc/hosts lists under the container's hostname instead, which
# only :: answers.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/app/webhooker"] CMD ["/app/webhooker"]
+12 -5
View File
@@ -16,6 +16,17 @@ VERSION ?= $(shell script/version)
# exists to correct. # exists to correct.
override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
# Extra linker flags and build tags for the build target. The static
# relink in the Dockerfile passes -extldflags and its build tags through
# these rather than a -ldflags of its own, so composing flags cannot drop
# the version stamp.
#
# The build target itself always passes -trimpath and -s -w, as the Go
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
# debug information in the binary.
GO_LDFLAGS ?=
GO_TAGS ?=
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -43,12 +54,8 @@ fmt-check:
check: check:
@script/check @script/check
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
# debug information, and on Linux, the image's included, it is statically
# linked.
build: assets build: assets
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
+117 -152
View File
@@ -310,17 +310,16 @@ the namespace, whatever the process bound. Exposure is decided at the publish
flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is
the operator's to choose and is what [Running with Docker](#running-with-docker) the operator's to choose and is what [Running with Docker](#running-with-docker)
shows. A loopback bind inside a container buys nothing and makes the process shows. A loopback bind inside a container buys nothing and makes the process
unreachable through its own published port. A container on an IPv6-only Docker unreachable through its own published port.
network has no IPv4 address for `0.0.0.0` to answer on, so there it needs
`BIND_ADDRESS=::`.
The value must be an IP address literal: The value must be an IP address literal:
- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse - `127.0.0.1` — loopback only (the binary's default). Use this with a reverse
proxy on the same host. proxy on the same host.
- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a - `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only
bare host, only behind a firewall on the port. behind a firewall on the port.
- `::` — every IPv6 address and every IPv4 address. - `::` — every address, IPv6 and (on Linux, with the default
`net.ipv6.bindv6only=0`) IPv4 as well.
- A specific address such as `10.0.0.5` — that interface only. - A specific address such as `10.0.0.5` — that interface only.
An **empty** value is treated as unset, as everywhere else here, and takes the An **empty** value is treated as unset, as everywhere else here, and takes the
@@ -341,8 +340,7 @@ machine that has no such interface — is a valid literal, so it reaches the
listener and fails there. The process logs the bind error and exits non-zero listener and fails there. The process logs the bind error and exits non-zero
rather than staying up with nothing listening. The effective value is in the rather than staying up with nothing listening. The effective value is in the
`bindAddress` field of the startup log line, which is the way to check what a `bindAddress` field of the startup log line, which is the way to check what a
running deployment actually bound. On a bare host, `ss -ltn` lists the socket running deployment actually bound.
itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`.
#### Metrics credentials #### Metrics credentials
@@ -670,9 +668,9 @@ Either set a literal or leave the variable out entirely.
Overriding `BIND_ADDRESS` to a loopback address in a container — by that route Overriding `BIND_ADDRESS` to a loopback address in a container — by that route
or deliberately — makes the container unreachable from outside its namespace or deliberately — makes the container unreachable from outside its namespace
even with `-p`. The published port answers nothing, and the health check fails even with `-p`. The published port answers nothing, and the health check fails
too: it requests the container's own address (its IPv4 address, or its IPv6 too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first,
address on an IPv6-only network), where a loopback bind is not listening. The and a `127.0.0.1` bind is not listening there. The container then goes
container then goes `unhealthy` about **65 seconds** after start — from `unhealthy` about **65 seconds** after start — from
`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at `HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during 5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during
the start period has changed between versions; re-derive from those three values the start period has changed between versions; re-derive from those three values
@@ -728,8 +726,8 @@ the credentials.
address (directly, or through anything that can rewrite source addresses, address (directly, or through anything that can rewrite source addresses,
such as NAT or a published container port). The `remoteIP` field of the such as NAT or a published container port). The `remoteIP` field of the
`http request` log line for a request that came through the proxy shows `http request` log line for a request that came through the proxy shows
that address; the health check's own lines show the container's own that address; the health check's own lines show `::1`. See
address. See [Trusted proxies](#trusted-proxies). [Trusted proxies](#trusted-proxies).
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS`
to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`.
- Everything else is optional; see [Configuration](#configuration). - Everything else is optional; see [Configuration](#configuration).
@@ -804,12 +802,7 @@ comes up, serves pages, and is broken in a way nothing reports.
proxy's log names it regardless. nginx's default `combined` format already proxy's log names it regardless. nginx's default `combined` format already
logs `$remote_addr`; do not replace it with one that drops the client logs `$remote_addr`; do not replace it with one that drops the client
address, and retain those logs as long as you would want to answer a question address, and retain those logs as long as you would want to answer a question
about traffic. That log also records the full request path, so it holds the about traffic.
entrypoint UUID of every event a sender posts. webhooker's own log does not,
apart from the line a `log` target writes. Protect the proxy's log as you
would the entrypoint URLs themselves: anyone who can read it can post events
to every entrypoint in it (see
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
### nginx ### nginx
@@ -835,9 +828,7 @@ server {
client_max_body_size 1m; client_max_body_size 1m;
# $remote_addr is the client. webhooker's own log names it, as # $remote_addr is the client. webhooker's own log names it, as
# clientIP, only while TRUSTED_PROXIES covers this proxy. The # clientIP, only while TRUSTED_PROXIES covers this proxy.
# request path in this log holds the entrypoint UUID, so protect
# the file like the entrypoint URL itself.
access_log /var/log/nginx/webhooker.access.log combined; access_log /var/log/nginx/webhooker.access.log combined;
location / { location / {
@@ -1169,16 +1160,7 @@ What that means for an operator:
- **The URL is a capability, so treat it as a secret.** Keep it out of logs, - **The URL is a capability, so treat it as a secret.** Keep it out of logs,
ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can
post events as that sender. Apart from a `log` target's line, webhooker's own post events as that sender.
log lines never carry the UUID: they name a request to the receiver by the
route pattern `/h/{uuid}`, the `webhook request received` line names the
entrypoint by its `entrypoint_id`, and the access log keeps only the scheme
and host of a `Referer`. A `log` target writes the request's headers as
received, so a `Referer` naming the entrypoint URL appears in its line, as it
does when a sender has followed a redirect from `http://` to `https://`. The
reverse proxy's access log does carry the UUID, in the request path, so it
must be protected like the URL itself (see
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
- **Rotating means minting a new entrypoint, not changing a key.** There is no - **Rotating means minting a new entrypoint, not changing a key.** There is no
way to rotate the UUID in place. To retire one, delete the entrypoint (or way to rotate the UUID in place. To retire one, delete the entrypoint (or
deactivate it, which answers `410`) and create a new one, then point the deactivate it, which answers `410`) and create a new one, then point the
@@ -1253,37 +1235,34 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never `static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`. `x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the sign-in page, the webhook page, A browser test in `internal/server` loads the webhook page, its edit pages and
its edit pages, the event log, the webhook list, the settings page, the profile the event log under the real policy and checks that: the add entrypoint form
page and the `404` error page shown for a path that does not exist, under the stays hidden until Add is clicked; for every target type, the targets section's
real policy and checks that: the add entrypoint form stays hidden until Add is Add shows only a choice of type with Next and Cancel, Next shows only that
clicked; for every target type, the targets section's Add shows only a choice of type's fields (no url field for `database` or `log`), Cancel at either step
type with Next and Cancel, Next shows only that type's fields (no url field for closes the form, and saving adds the target; a refused target comes back with
`database` or `log`), Cancel at either step closes the form, and saving adds the its form open, the values entered and the reason, and after Cancel the next Add
target; a refused target comes back with its form open, the values entered and starts with an empty form and no reason; a refused save on the target edit page
the reason, and after Cancel the next Add starts with an empty form and no and on the webhook edit page comes back with the reason and every value entered;
reason; a refused save on the target edit page and on the webhook edit page the Copy button beside an entrypoint URL reads "Copied" once clicked; an
comes back with the reason and every value entered; the Copy button beside an entrypoint's Edit button shows its edit form in place of its description and
entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows hides until the form closes, Cancel hides the form and drops what was typed, as
its edit form in place of its description and hides until the form closes, does leaving the page and going back to it, and Save changes the description; of
Cancel hides the form and drops what was typed, as does leaving the page and the recent events on the webhook page only the newest starts expanded, each
going back to it, and Save changes the description; of the recent events on the expands and collapses, and Open leads to the event's own page; of the events in
webhook page only the newest starts expanded, each expands and collapses, and the event log only the newest starts expanded, and an event there expands and
Open leads to the event's own page; of the events in the event log only the collapses when its row's caret or its ID is clicked, and from the keyboard, but
newest starts expanded, and an event there expands and collapses when its row's not when its ID is selected with the mouse, and a delivery's attempts inside it
caret or its ID is clicked, and from the keyboard, but not when its ID is expand and collapse; and at phone width the menu button opens and closes the
selected with the mouse, and a delivery's attempts inside it expand and mobile menu, and neither the webhook page nor the event log, with a delivery's
collapse; at phone width the menu button opens and closes the mobile menu, and attempts open, scrolls sideways or cuts anything off at the page's or a card's
neither the webhook page nor the event log, with a delivery's attempts open, edge. It also fails if the browser reports a console warning or error, an
scrolls sideways or cuts anything off at the page's or a card's edge; and a uncaught exception, or anything the policy refused. `make check` and the image
target's Delete asks first, naming the target, sends nothing when the answer is build lint it but do not run it, and `make test` leaves it out (its file is
no and deletes the target when it is yes. It also fails if the browser reports a built only with the `browser` build tag). Run it with `make test-browser` after
console warning or error, an uncaught exception, or anything the policy refused. changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
`make check` and the image build lint it but do not run it, and `make test` runs the test in a digest-pinned headless browser image, so the host needs no
leaves it out (its file is built only with the `browser` build tag). Run it with browser.
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -1316,9 +1295,7 @@ and commit the regenerated file with the change. Tailwind takes classes only
from the files that `input.css` names in its `@source` lines; a class written in from the files that `input.css` names in its `@source` lines; a class written in
any other file is not generated until that file is named there too. `make check` any other file is not generated until that file is named there too. `make check`
and the image build fail when the committed file differs from what `make css` and the image build fail when the committed file differs from what `make css`
generates. `static/css/style.css` is hand-written and is not generated. The generates. `static/css/style.css` is hand-written and is not generated.
pages' Content-Security-Policy refuses inline styles, so a template styles an
element with a class, never a `style` attribute.
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256 `make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
pinned in the Dockerfile's stylesheet stages; it is never installed on the host. pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
@@ -1393,10 +1370,10 @@ It uses:
management management
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
- **[GORM](https://gorm.io)** for database access with - **[GORM](https://gorm.io)** for database access with
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime
Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on
`mattn/go-sqlite3`, whose stub for builds without cgo is linked into the `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker)
binary but never used section)
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with - **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with
TTY detection (text for dev, JSON for prod) TTY detection (text for dev, JSON for prod)
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted - **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted
@@ -1592,28 +1569,26 @@ A delivery destination for events. Each target defines where and how events
should be forwarded. should be forwarded.
| Field | Type | Description | | Field | Type | Description |
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook | | `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name | | `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) | | `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration | | `config` | JSON text | Type-specific configuration |
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | | `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries. **Relations:** Belongs to Webhook. Has many Deliveries.
**Target types:** **Target types:**
- **`http`** — Forward the event as an HTTP POST to a configured URL. - **`http`** — Forward the event as an HTTP POST to a configured URL. With
`max_retries` is the total number of delivery attempts, not retries on top of `max_attempts` at 1 the target makes a single attempt; above 1 it retries a
the first: when `max_retries` is 0 (the default), the target operates in failed delivery with exponential backoff, up to that many attempts in all,
fire-and-forget mode, a single attempt with no retries and no circuit breaker; behind a per-target circuit breaker.
a value of N makes up to N attempts in all, retrying failed deliveries with
exponential backoff and protecting them with a per-target circuit breaker.
- **`slack`** — Post the event as a formatted message to a Slack-compatible - **`slack`** — Post the event as a formatted message to a Slack-compatible
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
core as `http` and honours `max_retries` identically, circuit breaker core as `http` and honours `max_attempts` identically, circuit breaker
included. See the Slack target section under "Per-Webhook Event Databases" for included. See the Slack target section under "Per-Webhook Event Databases" for
the message format. the message format.
- **`database`** — Archive the full event as a row into the target's own archive - **`database`** — Archive the full event as a row into the target's own archive
@@ -2135,7 +2110,9 @@ endpoint. That is the JSON key; the error text for a missing one reads
`webhook_url is required`, which is the message, not the key. `webhook_url is required`, which is the message, not the key.
The database uses the The database uses the
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime,
though CGO is required at build time due to the transitive `mattn/go-sqlite3`
dependency from `gorm.io/driver/sqlite`.
#### Schema #### Schema
@@ -2186,11 +2163,11 @@ External Service
▼ ▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ HTTP Target│ │ HTTP Target│ │ Log Target │ │ HTTP Target│ │ HTTP Target│ │ Log Target │
│(max_retries│ │(max_retries│ │ (stdout) │ │max_attempts│ │max_attempts│ │ (stdout) │
│ == 0) │ │ > 0, │ └────────────┘ │ == 1, │ │ > 1, │ └────────────┘
│ fire+forget│ │ backoff + │ │ one attempt│ │ backoff + │
└────────────┘ │ circuit │ └────────────┘ │ circuit │
│ breaker) │ │ breaker │
└────────────┘ └────────────┘
``` ```
@@ -2267,7 +2244,7 @@ re-inject it to every currently active target.
### Circuit Breaker (HTTP and Slack Targets with Retries) ### Circuit Breaker (HTTP and Slack Targets with Retries)
`http` and `slack` targets with `max_retries` > 0 are protected by a `http` and `slack` targets with `max_attempts` > 1 are protected by a
**per-target circuit breaker** that prevents hammering a down target with **per-target circuit breaker** that prevents hammering a down target with
repeated failed delivery attempts. The circuit breaker is in-memory only and repeated failed delivery attempts. The circuit breaker is in-memory only and
resets on restart (which is fine — startup recovery rescans the database resets on restart (which is fine — startup recovery rescans the database
@@ -2308,10 +2285,10 @@ anyway).
- **Cooldown:** 30 seconds in open state before probing - **Cooldown:** 30 seconds in open state before probing
**Scope:** Circuit breakers apply to **`http` and `slack` targets with **Scope:** Circuit breakers apply to **`http` and `slack` targets with
`max_retries` > 0**. The Slack target is built on the same HTTP core and hands `max_attempts` > 1**. The Slack target is built on the same HTTP core and hands
its own `max_retries` to the same retry path, so it gets a breaker with the same its own `max_attempts` to the same retry path, so it gets a breaker with the
5-failure / 30-second defaults. Fire-and-forget targets of either type same 5-failure / 30-second defaults. Single-attempt targets of either type
(`max_retries` == 0), database targets (local operations), and log targets (`max_attempts` == 1), database targets (local operations), and log targets
(stdout) do not use circuit breakers. (stdout) do not use circuit breakers.
When a circuit is open and a new delivery arrives, the engine marks the delivery When a circuit is open and a new delivery arrives, the engine marks the delivery
@@ -2443,32 +2420,27 @@ log volume it caps rather than eliminates. A path that names no entrypoint is
recorded by the handler at `DEBUG`, and the aggregate limiter logs its own recorded by the handler at `DEBUG`, and the aggregate limiter logs its own
rejections at `DEBUG` and without the path, so neither appears at all under the rejections at `DEBUG` and without the path, so neither appears at all under the
default level. The per-entrypoint limiter is the loud one: it logs every default level. The per-entrypoint limiter is the loud one: it logs every
rejection at `WARN`, naming the route pattern `/h/{uuid}` rather than the rejection at `WARN` with the request path, which on this route is
request path. A client hammering a single invented path is served attacker-controlled text. A client hammering a single invented path is served
`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected `RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected
there, so the aggregate limit is what bounds the _number_ of those `WARN` lines there, so the aggregate limit is what bounds the _number_ of those `WARN` lines
— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the — to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
defaults, where before it there was no bound at all. The access log is bounded defaults, where before it there was no bound at all. Their _width_ is bounded by
by neither limit: every request is recorded once at `INFO`, served or rejected the field budgets below, the same ones the access log spends. The access log is
alike. bounded by neither limit: every request is recorded once at `INFO`, served or
rejected alike.
What the access log does bound is the _content_ of those lines. On the receiver What the access log does bound is the _content_ of those lines. A 3xx or 4xx
route it logs the route pattern `/h/{uuid}` whatever the status, because the response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the
concrete path carries the entrypoint UUID, which is the entrypoint's credential literal `(unmatched)` when the request hit no route at all — in place of the
(see concrete URL. Those are the outcomes an unauthenticated client can drive for
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)). free: 404 and 429 on any invented receiver path, a login redirect on any
A request chi never routed, because it matched no route or because a middleware invented profile path. Logging the URL there would let a flood write text of its
answered it first, logs the literal `(unmatched)` whatever the status. In the own choosing, at a length of its own choosing, into the log. 2xx and 5xx
`dev` environment the CORS middleware answers a preflight that way, so a responses keep the concrete path — a success resolved against a static route or
preflight to the receiver does not log its UUID either. On every other route, a against the operator's own data (on the receiver, against a stored entrypoint
3xx or 4xx response logs the chi route pattern, such as `/user/{username}//`, in UUID), and a 5xx is a bug in this service, where the exact path is the evidence
place of the concrete URL. Those are the outcomes an unauthenticated client can and no client can provoke one at will.
drive for free: a 404 on any invented path, a login redirect on any invented
profile path. Logging the URL there would let a flood write text of its own
choosing, at a length of its own choosing, into the log. 2xx and 5xx responses
keep the concrete path — a success resolved against a static route or against
the operator's own data, and a 5xx is a bug in this service, where the exact
path is the evidence and no client can provoke one at will.
The query string is never logged; it is replaced by the fixed marker The query string is never logged; it is replaced by the fixed marker
`?(redacted)`. It is client-chosen on every route, and `?(redacted)`. It is client-chosen on every route, and
@@ -2480,11 +2452,6 @@ to, `notice`, which names the line a page shows after an action, and the event
log's `show`, which picks the events it lists. A query string sent to an log's `show`, which picks the events it lists. A query string sent to an
entrypoint is not lost either: the event stores it, and the event log shows it. entrypoint is not lost either: the event stores it, and the event log shows it.
The `referer` field keeps only the scheme and host of the request's `Referer`,
and logs `(redacted)` for one without them, because its path can be an
entrypoint URL: a sender that follows the `http://` to `https://` redirect in
the nginx example sends the URL it was redirected from.
Client-supplied request content does not leave the host by the other route Client-supplied request content does not leave the host by the other route
either. The Sentry SDK attaches the request to every event it captures, either. The Sentry SDK attaches the request to every event it captures,
independently of the access log, and `SendDefaultPII=false` does not cover all independently of the access log, and `SendDefaultPII=false` does not cover all
@@ -2494,18 +2461,14 @@ are both taken unconditionally, the body precisely because these handlers call
body with `(redacted)`, drops cookies and the remote-address environment, and body with `(redacted)`, drops cookies and the remote-address environment, and
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`. `Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`.
`Referer` is sent as its scheme and host only, because its path can be an
entrypoint URL; `internal/referer.SchemeAndHost` does that reduction for both
the access log's `referer` field and this hook.
The same hook rewrites the request URL. The SDK builds it as The same hook rewrites the request URL. The SDK builds it as
`scheme://host/path` from the concrete path, which on the receiver route is `scheme://host/path` from the concrete path, which on the receiver route is
`/h/<uuid>` in full — and that UUID is a write capability, not an identifier: `/h/<uuid>` in full — and that UUID is a write capability, not an identifier:
anyone holding it can post events this service accepts and its targets then anyone holding it can post events this service accepts and its targets then
deliver. What is sent is the chi route pattern instead: `http://host/h/{uuid}`. deliver. A tracker has its own retention, access control and deletion policy, so
A tracker has its own retention, access control and deletion policy, so the the rule the access log follows above does not carry across that boundary. What
pattern is sent on every route, including the 2xx and 5xx responses outside the is sent is the chi route pattern instead: `http://host/h/{uuid}`.
receiver for which the access log keeps the concrete path.
The scheme and the host are kept, and everything else in the URL is discarded The scheme and the host are kept, and everything else in the URL is discarded
rather than edited, so a future SDK version that starts appending a query string rather than edited, so a future SDK version that starts appending a query string
@@ -2567,9 +2530,8 @@ receiver route. What survives still names the failing route — scheme, host,
route pattern, method — and `X-Request-Id` ties the event to the local access route pattern, method — and `X-Request-Id` ties the event to the local access
log line that holds the rest. Nothing dropped is needed for the likeliest use, log line that holds the rest. Nothing dropped is needed for the likeliest use,
debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and
`Referer`; the latter two are kept (`Referer` as its scheme and host, which is `Referer`; the latter two are kept, and the first is the scheme of the retained
all the check compares), and the first is the scheme of the retained URL, URL, because the SDK derives that scheme from
because the SDK derives that scheme from
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate `r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate
is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this
service now uses everywhere it decides transport: the SDK reports `http` for the service now uses everywhere it decides transport: the SDK reports `http` for the
@@ -2635,7 +2597,9 @@ than it:
| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ | | ------------------------------------------ | ------- | ------------------- | ------------------------------------------ |
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` | | `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` | | `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form | | `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form | | `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form | | `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
@@ -2663,12 +2627,13 @@ wider of the two handlers spends more on than the client spent sending them.
Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is
what the figure above states, and every row establishes it. what the figure above states, and every row establishes it.
Two of the sites go further and bound the whole flood's output — the total bytes Three of the sites go further and bound the whole flood's output — the total
a run of distinct invented values wrote, which is the shape an operator sizing bytes a run of distinct invented values wrote, which is the shape an operator
storage cares about. They are `request body exceeds limit` sizing storage cares about. They are `request body exceeds limit`
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`) and `user not found` (`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`),
(through `assertBoundedFlood`). The other rows carry no aggregate assertion; the `entrypoint not found` and `user not found` (the last two through
per-line ceiling is what they establish. `assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line
ceiling is what they establish.
`internal/logfield/logfield_test.go` measures the per-rune charge against what `internal/logfield/logfield_test.go` measures the per-rune charge against what
the handlers really emit, over roughly 3,000 code points on each, so an the handlers really emit, over roughly 3,000 code points on each, so an
@@ -2686,7 +2651,7 @@ everything else, so its lines take the level the operator set and the handler
`internal/logger` selected, and every value it emits is spent through the same `internal/logger` selected, and every value it emits is spent through the same
`internal/logfield` budget. A record-not-found is not logged as an error: it is `internal/logfield` budget. A record-not-found is not logged as an error: it is
the expected outcome on both of those paths, and each handler already records the expected outcome on both of those paths, and each handler already records
its own miss at `DEBUG`, without the SQL and within the ceiling above. Slow its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow
statements are kept, at `WARN`, above the same 200 ms threshold GORM used and statements are kept, at `WARN`, above the same 200 ms threshold GORM used and
with the statement bounded, because that report is the one thing GORM's logger with the statement bounded, because that report is the one thing GORM's logger
gave an operator that nothing else here does. The adapter orders its cases gave an operator that nothing else here does. The adapter orders its cases
@@ -3101,8 +3066,6 @@ webhooker/
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages │ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages
│ ├── referer/
│ │ └── referer.go # SchemeAndHost: cuts a Referer down to its scheme and host, for the access log and the Sentry hook
│ ├── reqtls/ │ ├── reqtls/
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
│ ├── server/ │ ├── server/
@@ -3117,8 +3080,8 @@ webhooker/
│ ├── static.go # //go:embed directive │ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline, the rule hiding x-cloak elements │ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components │ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
@@ -3172,9 +3135,8 @@ Applied to all routes in this order:
1. **RequestID** — Generate unique request IDs (chi built-in) 1. **RequestID** — Generate unique request IDs (chi built-in)
2. **SecurityHeaders** — Production security headers on every response (HSTS 2. **SecurityHeaders** — Production security headers on every response (HSTS
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
`default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy, Referrer-Policy, Permissions-Policy)
Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, latency, 3. **Logging** — Structured request logging (method, URL, status, latency,
remote IP, client IP, user agent, request ID) remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and 4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
@@ -3259,12 +3221,6 @@ credential check, see [The login endpoint](#the-login-endpoint).
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is - Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`), `0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
Content-Security-Policy, Referrer-Policy, and Permissions-Policy Content-Security-Policy, Referrer-Policy, and Permissions-Policy
- The Content-Security-Policy is
`default-src 'self'; script-src 'self'; style-src 'self'`: a page takes
scripts and styles only from files webhooker serves, and the browser runs no
inline script or event handler and ignores any `<style>` element or `style`
attribute in a page. The pages need none: their scripts are in `static/js/`
and their styles in `static/css/`
- Request body size limits (1 MB) on all form POST endpoints, enforced by - Request body size limits (1 MB) on all form POST endpoints, enforced by
middleware that runs before CSRF parses the form middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all - **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
@@ -3472,12 +3428,16 @@ fixed independently of the compiler's:
a step's log, and then fails. `make test` builds this stage alone. a step's log, and then fails. `make test` builds this stage alone.
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test 6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
phases and the `css-check` and `markdown-check` stages passing (it copies a phases and the `css-check` and `markdown-check` stages passing (it copies a
file from each), and runs `make build` (which extracts Alpine.js from `3p/` file from each), runs `make build` (which extracts Alpine.js from `3p/`
first). `make build` compiles with cgo off, so the binary is statically first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
linked and runs on the Alpine runtime image. The version is the `VERSION` so it runs on musl, with the build tags `netgo`, `osusergo` and
build arg if one is given, otherwise derived from the `.git` in the context, `sqlite_omit_load_extension` so it needs none of glibc's shared libraries at
and the stage fails if a context with `.git` would stamp an empty version, run time. Both builds go through `make build`, the relink adding its
`dev` or `unknown` (see [Version stamping](#version-stamping)). `-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can
drop the `-X` that stamps the version. The version is the `VERSION` build arg
if one is given, otherwise derived from the `.git` in the context, and the
stage fails if a context with `.git` would stamp an empty version, `dev` or
`unknown` (see [Version stamping](#version-stamping)).
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and 7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
all SQLite databases, exposes port 8080, and includes a health check against all SQLite databases, exposes port 8080, and includes a health check against
@@ -3489,6 +3449,11 @@ The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly
rather than `make fmt-check`, `make lint` and `make test`: those targets build rather than `make fmt-check`, `make lint` and `make test`: those targets build
docker stages, which would need a docker daemon inside this build. docker stages, which would need a docker daemon inside this build.
The lint phase and the Go stages use Debian rather than Alpine because
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does
not compile against musl. Only the final binary is statically linked, which is
what lets it run on the Alpine runtime image.
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then `script/cibuild` is the CI gate: it runs `script/bootstrap`, then
`script/check`, then builds the image, whose build runs the lint and test phases `script/check`, then builds the image, whose build runs the lint and test phases
and the stylesheet and Markdown checks again. A build that succeeds is a repo and the stylesheet and Markdown checks again. A build that succeeds is a repo
+5 -3
View File
@@ -29,9 +29,11 @@ type Target struct {
// handler that marshals the model itself. // handler that marshals the model itself.
Config string `gorm:"type:text" json:"-"` // JSON configuration Config string `gorm:"type:text" json:"-"` // JSON configuration
// For HTTP targets (max_retries=0 means fire-and-forget, // MaxAttempts is how many times an HTTP or Slack target attempts
// >0 enables retries with backoff) // each delivery in all, at least 1. 1 is a single attempt with no
MaxRetries int `json:"maxRetries,omitempty"` // circuit breaker; above 1, a failed attempt is retried with
// backoff behind the breaker.
MaxAttempts int `json:"maxAttempts,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop. // Webhook.Targets leads back here, and the JSON could loop.
+1 -1
View File
@@ -93,7 +93,7 @@ func TestMainSchemaHoldsEveryModel(t *testing.T) {
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Active: true, Active: true,
Config: `{"url":"https://example.com/"}`, Config: `{"url":"https://example.com/"}`,
MaxRetries: 3, MaxAttempts: 3,
}) })
} }
+1 -1
View File
@@ -80,7 +80,7 @@ CREATE TABLE targets (
type text NOT NULL, type text NOT NULL,
active numeric DEFAULT true, active numeric DEFAULT true,
config text, config text,
max_retries integer, max_attempts integer,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_webhooks_targets CONSTRAINT fk_webhooks_targets
FOREIGN KEY (webhook_id) REFERENCES webhooks (id) FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
+4 -4
View File
@@ -107,7 +107,7 @@ type Task struct {
TargetName string TargetName string
TargetType database.TargetType TargetType database.TargetType
TargetConfig string TargetConfig string
MaxRetries int MaxAttempts int
Method string Method string
RawQuery string RawQuery string
@@ -1772,7 +1772,7 @@ func buildTargetFromTask(task *Task) database.Target {
Name: task.TargetName, Name: task.TargetName,
Type: task.TargetType, Type: task.TargetType,
Config: task.TargetConfig, Config: task.TargetConfig,
MaxRetries: task.MaxRetries, MaxAttempts: task.MaxAttempts,
} }
target.ID = task.TargetID target.ID = task.TargetID
@@ -2028,7 +2028,7 @@ func (e *Engine) countAttemptsBatch(
// One delivery id per recorded attempt, tallied here rather than // One delivery id per recorded attempt, tallied here rather than
// grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which // grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which
// a GROUP BY into a struct would need, and an attempt row per // a GROUP BY into a struct would need, and an attempt row per
// delivery is bounded by the target's MaxRetries. // delivery is bounded by the target's MaxAttempts.
var attemptIDs []string var attemptIDs []string
err := webhookDB. err := webhookDB.
@@ -2106,7 +2106,7 @@ func buildRecoveryTask(
TargetName: target.Name, TargetName: target.Name,
TargetType: target.Type, TargetType: target.Type,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxAttempts: target.MaxAttempts,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
+16 -16
View File
@@ -170,7 +170,7 @@ func iTask(
d database.Delivery, d database.Delivery,
event database.Event, event database.Event,
webhookID, targetID, name, config string, webhookID, targetID, name, config string,
maxRetries, attemptNum int, maxAttempts, attemptNum int,
body *string, body *string,
) delivery.Task { ) delivery.Task {
return delivery.Task{ return delivery.Task{
@@ -181,7 +181,7 @@ func iTask(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxRetries: maxRetries, MaxAttempts: maxAttempts,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
@@ -242,7 +242,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-target", cfg, 0, 1, &bodyStr, "test-target", cfg, 1, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -293,7 +293,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-large", cfg, 0, 1, nil, "test-large", cfg, 1, 1, nil,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -320,7 +320,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
TargetName: "test", TargetName: "test",
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: iHTTPConfig("http://localhost:9999"), TargetConfig: iHTTPConfig("http://localhost:9999"),
MaxRetries: 0, MaxAttempts: 1,
Body: nil, Body: nil,
AttemptNum: 1, AttemptNum: 1,
} }
@@ -645,7 +645,7 @@ func TestRecoverPendingDeliveries(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recovery-target", s.WebhookID, "recovery-target",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
iSeedPendingDeliveries( iSeedPendingDeliveries(
@@ -686,7 +686,7 @@ func iCreateTarget(
targetID, webhookID, name string, targetID, webhookID, name string,
targetType database.TargetType, targetType database.TargetType,
config string, config string,
maxRetries int, maxAttempts int,
) { ) {
t.Helper() t.Helper()
@@ -696,7 +696,7 @@ func iCreateTarget(
Type: targetType, Type: targetType,
Active: true, Active: true,
Config: config, Config: config,
MaxRetries: maxRetries, MaxAttempts: maxAttempts,
} }
target.ID = targetID target.ID = targetID
@@ -1077,7 +1077,7 @@ func TestRecoverInFlight_WithPendingDeliveries(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recover-target", s.WebhookID, "recover-target",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -1232,7 +1232,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"auth-target", string(cfgJSON), "auth-target", string(cfgJSON),
0, 1, &bodyStr, 1, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -1277,7 +1277,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "timeout-target", event, del, "timeout-target",
string(cfgJSON), 0, 1, string(cfgJSON), 1, 1,
) )
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1339,7 +1339,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
del, event, s.WebhookID, targetID, del, event, s.WebhookID, targetID,
"cut-target", cfgJSON, 0, 1, &bodyStr, "cut-target", cfgJSON, 1, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -1403,7 +1403,7 @@ func iHTTPTaskAndDelivery(
event database.Event, event database.Event,
del database.Delivery, del database.Delivery,
name, config string, name, config string,
maxRetries, attemptNum int, maxAttempts, attemptNum int,
) (*delivery.Task, *database.Delivery) { ) (*delivery.Task, *database.Delivery) {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: del.ID, DeliveryID: del.ID,
@@ -1413,7 +1413,7 @@ func iHTTPTaskAndDelivery(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxRetries: maxRetries, MaxAttempts: maxAttempts,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -1466,7 +1466,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
) )
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1, event, del, "bad-config", `not-json`, 1, 1,
) )
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1490,7 +1490,7 @@ func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
) )
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1, event, del, "bad-config", `not-json`, 1, 1,
) )
require.NoError(t, db.Exec("drop table delivery_results").Error) require.NoError(t, db.Exec("drop table delivery_results").Error)
+7 -7
View File
@@ -278,7 +278,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
} }
// seedHeldDelivery gives the setup's webhook an http target with // seedHeldDelivery gives the setup's webhook an http target with
// maxRetries, pointed at a receiver that keeps every request open until // maxAttempts, pointed at a receiver that keeps every request open until
// the client gives up on it, and seeds one delivery to that target at // the client gives up on it, and seeds one delivery to that target at
// status. It returns the task that sends attempt attemptNum of the // status. It returns the task that sends attempt attemptNum of the
// delivery, and a channel that is closed when a request reaches the // delivery, and a channel that is closed when a request reaches the
@@ -286,7 +286,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
func seedHeldDelivery( func seedHeldDelivery(
t *testing.T, t *testing.T,
s iSetup, s iSetup,
maxRetries int, maxAttempts int,
status database.DeliveryStatus, status database.DeliveryStatus,
attemptNum int, attemptNum int,
) (delivery.Task, <-chan struct{}) { ) (delivery.Task, <-chan struct{}) {
@@ -315,7 +315,7 @@ func seedHeldDelivery(
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held", t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, cfg, maxRetries, database.TargetTypeHTTP, cfg, maxAttempts,
) )
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
@@ -325,7 +325,7 @@ func seedHeldDelivery(
return iTask( return iTask(
d, event, s.WebhookID, targetID, "held", cfg, d, event, s.WebhookID, targetID, "held", cfg,
maxRetries, attemptNum, &bodyStr, maxAttempts, attemptNum, &bodyStr,
), arrived ), arrived
} }
@@ -380,7 +380,7 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery( task, arrived := seedHeldDelivery(
t, s, 0, database.DeliveryStatusPending, 1, t, s, 1, database.DeliveryStatusPending, 1,
) )
s.Engine.Notify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
@@ -404,13 +404,13 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel() t.Parallel()
const maxRetries = 2 const maxAttempts = 2
s := newISetup(t) s := newISetup(t)
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery( task, arrived := seedHeldDelivery(
t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries, t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts,
) )
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID) iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
+100 -14
View File
@@ -142,7 +142,7 @@ func buildHTTPFixture(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, config string, targetID, name, config string,
maxRetries, attemptNum int, maxAttempts, attemptNum int,
) httpDeliveryFixture { ) httpDeliveryFixture {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -152,7 +152,7 @@ func buildHTTPFixture(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxRetries: maxRetries, MaxAttempts: maxAttempts,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -165,7 +165,7 @@ func buildHTTPFixture(
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: config, Config: config,
MaxRetries: maxRetries, MaxAttempts: maxAttempts,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -277,7 +277,7 @@ func TestDeliverHTTP_Success(t *testing.T) {
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, "test-http", cfg, 0, 1, dlv, event, targetID, "test-http", cfg, 1, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -324,7 +324,7 @@ func TestDeliverHTTP_Failure(t *testing.T) {
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-fail", cfg, 0, 1, "test-http-fail", cfg, 1, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -473,14 +473,23 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
) )
} }
func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) { // TestDeliverHTTP_MaxAttemptsExhausted proves a target with three
// delivery attempts makes at most three: the first two failures are
// retried, the third is final, and each attempt goes through the
// target's circuit breaker.
func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
t.Parallel() t.Parallel()
const maxAttempts = 3
db := testWebhookDB(t) db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
}, },
), ),
@@ -489,26 +498,103 @@ func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
// It opens on the last failure only if every attempt reached it.
cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute)
e.ExportSetCircuitBreaker(targetID, cb)
event := seedEvent(t, db, `{"retry":"exhaust"}`) event := seedEvent(t, db, `{"retry":"exhaust"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID, targetID,
database.DeliveryStatusRetrying, database.DeliveryStatusPending,
)
cfg := newHTTPTargetConfig(ts.URL)
sched := &recordingScheduler{}
for attempt := 1; attempt <= maxAttempts; attempt++ {
fix := buildHTTPFixture(
dlv, event, targetID,
"test-http-exhaust", cfg, maxAttempts, attempt,
)
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
)
}
assert.Equal(t, int32(maxAttempts), requests.Load())
assert.Len(t, sched.delays, maxAttempts-1,
"every failure but the last should be retried",
)
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed,
)
assert.Equal(t, delivery.CircuitOpen, cb.State(),
"every attempt should be counted by the circuit breaker",
)
}
// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target
// with one delivery attempt makes exactly that attempt, even while
// its circuit breaker is open, and that a failure is final.
func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway)
},
),
)
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
cb := e.ExportGetCircuitBreaker(targetID)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
require.Equal(t, delivery.CircuitOpen, cb.State())
event := seedEvent(t, db, `{"retry":"none"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-exhaust", cfg, 3, 3, "test-http-single", cfg, 1, 1,
)
sched := &recordingScheduler{}
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
) )
e.ExportDeliverHTTP( assert.Equal(t, int32(1), requests.Load(),
context.TODO(), db, fix.Delivery, fix.Task, "the open circuit breaker should not hold the attempt back",
) )
assert.Empty(t, sched.delays, "no retry should be scheduled")
assertDeliveryStatus(t, db, dlv.ID, assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
assertDeliveryResult(
t, db, dlv.ID, false, http.StatusBadGateway,
)
} }
func TestDeliverHTTP_SchedulesRetryOnFailure( func TestDeliverHTTP_SchedulesRetryOnFailure(
@@ -741,7 +827,7 @@ func seedConcurrencyTasks(
TargetName: tasks[i].Target.Name, TargetName: tasks[i].Target.Name,
TargetType: tasks[i].Target.Type, TargetType: tasks[i].Target.Type,
TargetConfig: tasks[i].Target.Config, TargetConfig: tasks[i].Target.Config,
MaxRetries: 0, MaxAttempts: 1,
AttemptNum: 1, AttemptNum: 1,
} }
} }
@@ -2054,7 +2140,7 @@ func buildSlackRetryDelivery(
Name: "test-slack-retry", Name: "test-slack-retry",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Config: cfg, Config: cfg,
MaxRetries: 5, MaxAttempts: 5,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -2092,7 +2178,7 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
TargetID: targetID, TargetID: targetID,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
MaxRetries: 5, MaxAttempts: 5,
AttemptNum: 1, AttemptNum: 1,
} }
+1 -1
View File
@@ -152,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", cfg, 0, 1, &body, "leaky-target", cfg, 1, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
+1 -1
View File
@@ -176,7 +176,7 @@ func tsTask(
TargetName: target.Name, TargetName: target.Name,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: 0, MaxAttempts: 1,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
+1 -1
View File
@@ -229,7 +229,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "released", s.WebhookID, "released",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
+10 -10
View File
@@ -291,20 +291,20 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
// A retry budget above the failure threshold, so the breaker // An attempt budget above the failure threshold, so the breaker
// rather than the budget is what stops the delivery. // rather than the budget is what stops the delivery.
maxRetries := delivery.ExportDefaultFailureThreshold + 5 maxAttempts := delivery.ExportDefaultFailureThreshold + 5
// The retries below are only run if their target still exists; // The retries below are only run if their target still exists;
// see https://git.eeqj.de/sneak/webhooker/issues/107. // see https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-trip", t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
database.TargetTypeHTTP, cfg, maxRetries, database.TargetTypeHTTP, cfg, maxAttempts,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxRetries, 1, &body, "metrics-trip", cfg, maxAttempts, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -316,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxRetries, attempt, &body, "metrics-trip", cfg, maxAttempts, attempt, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -364,16 +364,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
maxRetries := delivery.ExportDefaultFailureThreshold + 5 maxAttempts := delivery.ExportDefaultFailureThreshold + 5
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
database.TargetTypeHTTP, cfg, maxRetries, database.TargetTypeHTTP, cfg, maxAttempts,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries, 1, &body, "metrics-blocked", cfg, maxAttempts, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -382,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries, attempt, &body, "metrics-blocked", cfg, maxAttempts, attempt, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &task) s.Engine.ExportProcessRetryTask(context.TODO(), &task)
@@ -405,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
blocked := iTask( blocked := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries, "metrics-blocked", cfg, maxAttempts,
delivery.ExportDefaultFailureThreshold+1, &body, delivery.ExportDefaultFailureThreshold+1, &body,
) )
@@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "already-delivered", s.WebhookID, "already-delivered",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "numbering", s.WebhookID, "numbering",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -447,7 +447,7 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL), Config: iHTTPConfig(ts.URL),
MaxRetries: 3, MaxAttempts: 3,
}, },
} }
full.ID = d.ID full.ID = d.ID
+9 -14
View File
@@ -120,8 +120,8 @@ func unavailableConfigFields() []ConfigField {
} }
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target: its masked
// webhook URL and its retry count. Only the masked URL is // webhook URL and its number of delivery attempts. Only the
// shown; the full URL is the credential. // masked URL is shown; the full URL is the credential.
func slackConfigFields(t *database.Target) []ConfigField { func slackConfigFields(t *database.Target) []ConfigField {
cfg, err := parseSlackConfig(t.Config) cfg, err := parseSlackConfig(t.Config)
if err != nil { if err != nil {
@@ -131,7 +131,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(), Value: cfg.MaskedWebhookURL(),
}, maxRetriesField(t)} }, maxAttemptsField(t)}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -178,23 +178,18 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
fields = append(fields, maxRetriesField(t)) fields = append(fields, maxAttemptsField(t))
return fields return fields
} }
// maxRetriesField describes a target's retry count, which lives // maxAttemptsField describes a target's number of delivery
// on the target row rather than in its configuration blob. A // attempts, which lives on the target row rather than in its
// stored 0 makes a single attempt, so it is shown as 1. // configuration blob.
func maxRetriesField(t *database.Target) ConfigField { func maxAttemptsField(t *database.Target) ConfigField {
attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Delivery attempts",
Value: attempts, Value: strconv.Itoa(t.MaxAttempts),
} }
} }
+16 -11
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Delivery attempts" viewMaxAttempts = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -171,6 +171,7 @@ func slackTarget() database.Target {
Active: true, Active: true,
Config: `{"webhookUrl":"` + Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`, slackWebhookURL + `"}`,
MaxAttempts: 1,
} }
} }
@@ -190,19 +191,20 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", viewMaxAttempts: "1",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
} }
// TestNewTargetViews_SlackRetries proves a Slack target shows // TestNewTargetViews_SlackAttempts proves a Slack target shows
// its retry count the same way an HTTP target does. // its number of delivery attempts the same way an HTTP target
func TestNewTargetViews_SlackRetries(t *testing.T) { // does.
func TestNewTargetViews_SlackAttempts(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() target := slackTarget()
target.MaxRetries = 2 target.MaxAttempts = 2
view := viewFor(t, target) view := viewFor(t, target)
@@ -210,7 +212,7 @@ func TestNewTargetViews_SlackRetries(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "2", viewMaxAttempts: "2",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -225,7 +227,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` + `"headers":{"Authorization":"Bearer sekrit"},` +
`"forwardQuery":true}`, `"forwardQuery":true}`,
MaxRetries: 5, MaxAttempts: 5,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -237,7 +239,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
"Query string": "passed on to this target", "Query string": "passed on to this target",
viewMaxRetries: "5", viewMaxAttempts: "5",
}, },
fields, fields,
) )
@@ -248,19 +250,22 @@ func TestNewTargetViews_HTTP(t *testing.T) {
} }
} }
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) { // TestNewTargetViews_HTTPSingleAttempt proves a single attempt is
// shown as the plain number, like any other.
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `"}`, Config: `{"url":"` + viewExampleHook + `"}`,
MaxAttempts: 1,
}) })
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", viewMaxAttempts: "1",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
+12 -12
View File
@@ -53,10 +53,10 @@ type httpCore struct {
} }
// deliver runs one delivery attempt through the retry core. // deliver runs one delivery attempt through the retry core.
// A maxRetries of 0 is fire-and-forget: a single attempt is // A maxAttempts of 1 is a single attempt, recorded without
// recorded and no circuit breaker is consulted. A positive // consulting a circuit breaker. A higher maxAttempts gates the
// maxRetries gates the attempt on the circuit breaker and // attempt on the circuit breaker and schedules a backed-off retry
// schedules a backed-off retry on failure. // on failure, until maxAttempts attempts have been made.
// //
// ctx is the context attempt sends its request with. Stopping the // ctx is the context attempt sends its request with. Stopping the
// engine cancels it; see interruptedByStop. // engine cancels it; see interruptedByStop.
@@ -66,17 +66,17 @@ func (c *httpCore) deliver(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxRetries int, maxAttempts int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
if maxRetries == 0 { if maxAttempts <= 1 {
c.fireAndForget(ctx, webhookDB, d, attempt()) c.fireAndForget(ctx, webhookDB, d, attempt())
return return
} }
c.withRetry( c.withRetry(
ctx, webhookDB, d, task, sched, maxRetries, attempt, ctx, webhookDB, d, task, sched, maxAttempts, attempt,
) )
} }
@@ -153,7 +153,7 @@ func (c *httpCore) withRetry(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxRetries int, maxAttempts int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
cb := c.getCircuitBreaker(task.TargetID) cb := c.getCircuitBreaker(task.TargetID)
@@ -205,7 +205,7 @@ func (c *httpCore) withRetry(
cb.RecordFailure() cb.RecordFailure()
c.handleRetry( c.handleRetry(
webhookDB, d, task, sched, maxRetries, attemptNum, webhookDB, d, task, sched, maxAttempts, attemptNum,
) )
} }
@@ -266,10 +266,10 @@ func (c *httpCore) handleRetry(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxRetries int, maxAttempts int,
attemptNum int, attemptNum int,
) { ) {
if attemptNum >= maxRetries { if attemptNum >= maxAttempts {
c.eng.settleStatus( c.eng.settleStatus(
webhookDB, d, d.Target.Type, webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
@@ -439,7 +439,7 @@ func (t *httpTarget) Deliver(
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, ctx, webhookDB, d, task, sched,
d.Target.MaxRetries, attempt, d.Target.MaxAttempts, attempt,
) )
} }
+3 -4
View File
@@ -30,9 +30,8 @@ type SlackTargetConfig struct {
// slackTarget delivers events to Slack incoming webhooks. It // slackTarget delivers events to Slack incoming webhooks. It
// formats the event into a Slack message and posts it as // formats the event into a Slack message and posts it as
// JSON. It shares the retry core with the HTTP target: a // JSON. It shares the retry core with the HTTP target: a
// MaxRetries of 0 stays single-attempt fire-and-forget // MaxAttempts of 1 is a single attempt, while a higher one
// (preserving existing Slack targets), while a positive // adds backoff and circuit breaking.
// MaxRetries adds backoff and circuit breaking.
type slackTarget struct { type slackTarget struct {
*httpCore *httpCore
@@ -83,7 +82,7 @@ func (t *slackTarget) Deliver(
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, ctx, webhookDB, d, task, sched,
d.Target.MaxRetries, attempt, d.Target.MaxAttempts, attempt,
) )
} }
+2 -2
View File
@@ -743,7 +743,7 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup", t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -790,7 +790,7 @@ func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "healthy", t, s.MainDB, targetID, s.WebhookID, "healthy",
database.TargetTypeLog, "", 0, database.TargetTypeLog, "", 1,
) )
event := iSeedEvent( event := iSeedEvent(
+1 -1
View File
@@ -308,7 +308,7 @@ func createReplayDelivery(
TargetName: target.Name, TargetName: target.Name,
TargetType: target.Type, TargetType: target.Type,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxAttempts: target.MaxAttempts,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
@@ -158,7 +158,8 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
// TestHandleSourceLogs_EscapesResponseBody proves the // TestHandleSourceLogs_EscapesResponseBody proves the
// response body is treated as the untrusted remote content it // response body is treated as the untrusted remote content it
// is. The remote chooses these bytes and the page is rendered // is. The remote chooses these bytes and the page is rendered
// inside the operator's authenticated origin. // inside the operator's authenticated origin, where the
// application's own CSP allows inline script from 'self'.
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) { func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
t.Parallel() t.Parallel()
+5 -4
View File
@@ -35,10 +35,11 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
// response is deliberately not renderable. Content-Disposition // response is deliberately not renderable. Content-Disposition
// makes the browser download rather than display it, and the // makes the browser download rather than display it, and the
// octet-stream type plus nosniff stop it being interpreted as // octet-stream type plus nosniff stop it being interpreted as
// HTML or script. Without those a stored payload would be shown // HTML or script. Without those a stored payload would execute
// as a page of the operator's own origin. The application's CSP // as the logged-in operator. The application's CSP does not
// is a second defence behind them: it allows no inline script, // help here: script-src allows 'unsafe-inline' from 'self', so
// so such a page could not run script of its own. // a document served from this origin could run its own inline
// script.
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc { func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r) webhook, ok := h.ownedWebhook(w, r)
+1 -1
View File
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes") assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx") assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}") assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`) assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first") assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name) assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>") assert.Contains(t, page, ">failed</span>")
+1 -1
View File
@@ -496,7 +496,7 @@ const deliveryIDChunkSize = 500
// //
// Each response body is cut by SQLite rather than in Go, for // Each response body is cut by SQLite rather than in Go, for
// the reason deliveryResultColumns gives. How many attempts a // the reason deliveryResultColumns gives. How many attempts a
// delivery has is the target's MaxRetries, which the // delivery has is bounded by the target's MaxAttempts, which the
// authenticated operator sets; how many of them reach the page // authenticated operator sets; how many of them reach the page
// is bounded again by maxRenderedAttempts. // is bounded again by maxRenderedAttempts.
func (h *Handlers) loadDeliveryResults( func (h *Handlers) loadDeliveryResults(
+2 -2
View File
@@ -37,10 +37,10 @@ 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 // MaxTargetAttemptsForTest exposes the target max_attempts ceiling to
// the handlers_test package, so the tests assert against the constant // the handlers_test package, so the tests assert against the constant
// the handlers enforce rather than a number copied beside it. // the handlers enforce rather than a number copied beside it.
const MaxTargetRetriesForTest = maxTargetRetries const MaxTargetAttemptsForTest = maxTargetAttempts
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two // EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event // messages the webhook delete handler logs when a file of the event
+49 -8
View File
@@ -1,15 +1,19 @@
package handlers_test package handlers_test
// The handler-side half of the log-field audit. The slog calls in // The handler-side half of the log-field audit. Two slog calls in
// this package that reach a value an UNAUTHENTICATED client picks // this package reach a value an UNAUTHENTICATED client picks outright
// outright, and of a length it picks outright, are the failed-login // and of a length it picks outright:
// DEBUG lines, whose username is a form field.
// //
// They are at DEBUG, which is off in production by default. That is // - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
// path segment matched no stored entrypoint and so is bounded by
// nothing;
// - the failed-login DEBUG lines, whose username is a form field.
//
// Both are at DEBUG, which is off in production by default. That is
// not a bound: an operator turning DEBUG on to diagnose a flood must // not a bound: an operator turning DEBUG on to diagnose a flood must
// not thereby hand the flood an unbounded write. They spend the same // not thereby hand the flood an unbounded write. Both spend the same
// internal/logfield budget as the access log, and are held here to // internal/logfield budget as the access log, and both are held here
// middleware.MaxAccessLogLineBytes. // to middleware.MaxAccessLogLineBytes.
// //
// The two login lines past the username lookup — "invalid password" // The two login lines past the username lookup — "invalid password"
// and "user logged in" — carry the same cap without needing it, since // and "user logged in" — carry the same cap without needing it, since
@@ -256,6 +260,43 @@ func postLoginWithPassword(
return w.Code return w.Code
} }
// TestUnknownEntrypoint_LogLineDoesNotTrackPathSize drives 8 KB of
// client-chosen path at the unauthenticated receiver's
// unknown-entrypoint DEBUG line and holds it to the same ceiling the
// access log states.
func TestUnknownEntrypoint_LogLineDoesNotTrackPathSize(t *testing.T) {
t.Parallel()
for handlerName, newHandler := range logHandlers() {
for fillName, fill := range escapeFills() {
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
t.Parallel()
h, buf := capturingHandlers(t, newHandler)
router := receiverRouter(h)
for i := range floodRequests {
assert.Equal(
t,
http.StatusNotFound,
postReceiver(
t, router,
oversizedFill(fill)+
strings.Repeat("y", i),
),
)
}
lines := logLines(t, buf)
require.Len(t, lines, floodRequests)
assertNoClientText(t, buf)
assertBoundedFlood(t, buf.Len())
})
}
}
}
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of // TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
// client-chosen username at the unauthenticated login endpoint's // client-chosen username at the unauthenticated login endpoint's
// DEBUG line and holds it to the same ceiling. // DEBUG line and holds it to the same ceiling.
+18 -17
View File
@@ -273,7 +273,7 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
assert.Contains( assert.Contains(
t, body, t, body,
`<div class="max-w-[108rem] mx-auto px-6 py-8">`, `<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
) )
assert.Contains( assert.Contains(
t, body, t, body,
@@ -286,8 +286,8 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
// target and says what deleting it loses, that the webhook's gives its // target and says what deleting it loses, that the webhook's gives its
// number of stored events (5 received, 2 removed by retention, so 3, // number of stored events (5 received, 2 removed by retention, so 3,
// the statistics pane's "Within retention" figure), and that an // the statistics pane's "Within retention" figure), and that an
// entrypoint with no description is named by its URL. Each prompt is // entrypoint with no description is named by its URL. The template
// its form's data-confirm attribute, where &#10; is a line break. // writes the slashes after http: as \/, which the browser reads as /.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) { func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -325,28 +325,29 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, assert.Contains(t, body,
`data-confirm="Delete webhook &quot;delete-me&quot;?&#10;&#10;`+ `Delete webhook &quot;delete-me&quot;?\n\n`+
`This deletes its stored events (3) and their deliveries. `+ `This deletes its stored events (3) and their deliveries. `+
`Any archive files it wrote are kept."`) `Any archive files it wrote are kept.`)
assert.Contains(t, body, assert.Contains(t, body,
`data-confirm="Delete entrypoint &quot;Stripe&quot;?&#10;&#10;`+ `Delete entrypoint &quot;Stripe&quot;?\n\n`+
`Senders using its URL get an error from now on, `+ `Senders using its URL get an error from now on, `+
`and the URL cannot be restored."`) `and the URL cannot be restored.`)
assert.Contains(t, body, assert.Contains(t, body,
`data-confirm="Delete entrypoint &quot;http://example.com/h/`+ `Delete entrypoint &quot;http:\/\/example.com/h/`+
unnamed.Path+`&quot;?&#10;&#10;`) unnamed.Path+`&quot;?`)
assert.Contains(t, body, assert.Contains(t, body,
`data-confirm="Delete target &quot;t-log&quot;?&#10;&#10;`+ `Delete target &quot;t-log&quot;?\n\n`+
`Nothing more is delivered to it. `+ `Nothing more is delivered to it. `+
`Its past deliveries stay in the event log."`) `Its past deliveries stay in the event log.`)
} }
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a // TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
// webhook name with quotes, a backslash, a closing script tag and a // webhook name with quotes, a backslash, a closing script tag and a
// newline reaches its delete prompt escaped for the attribute, which // newline reaches its delete prompt escaped for the script, which the
// the browser reads back as the name typed: each quote and angle // browser reads back as the name typed: each quote and angle bracket
// bracket as a character reference, the rest as typed. An unescaped // as a \u escape, the slash as \/, the newline as \n and the backslash
// double quote would end the attribute and cut the prompt short. // doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) { func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel() t.Parallel()
@@ -372,6 +373,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, assert.Contains(t, body,
"Delete webhook &quot;Bob&#39;s &#34;best&#34; \\ hook"+ "Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"&lt;/script&gt;\nline two&quot;?") "\\u003c\\/script\\u003e\\nline two&quot;?")
} }
+81
View File
@@ -0,0 +1,81 @@
package handlers
import (
"errors"
"strconv"
"strings"
)
// maxTargetAttempts bounds a target's max_attempts.
//
// 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 maxTargetAttempts = 20
// Errors returned when a max_attempts form value cannot be turned into
// a number of attempts.
var (
// errAttemptsInvalid signals a max_attempts form value that is not
// a whole number of at least 1.
errAttemptsInvalid = errors.New(
"must be a whole number, at least 1",
)
// errAttemptsTooLarge signals a max_attempts form value that is a
// whole number but above maxTargetAttempts. It is distinguished
// from errAttemptsInvalid so the message can name the ceiling
// instead of implying the input was not a number.
errAttemptsTooLarge = errors.New("out of range")
)
// parseMaxAttempts interprets a max_attempts 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, below 1, or above maxTargetAttempts.
//
// The distinction is the whole point of this function. Returning a
// default for input the operator typed but that did not parse would
// silently turn retries off on a store-and-forward proxy — and on the
// edit path destroy a working configuration over a typo. A default
// answers a question that was not asked; it never answers one that
// was asked badly.
func parseMaxAttempts(raw string, fallback int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 {
return 0, errAttemptsInvalid
}
if v > maxTargetAttempts {
return 0, errAttemptsTooLarge
}
return v, nil
}
// attemptsErrorMessage returns the message the create and edit forms
// show for a rejected max_attempts value. Any error other than
// errAttemptsTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400.
func attemptsErrorMessage(err error) string {
if errors.Is(err, errAttemptsTooLarge) {
return errAttemptsTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetAttempts) +
" attempts"
}
return errAttemptsInvalid.Error()
}
+429
View File
@@ -0,0 +1,429 @@
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"
)
// attemptsTargetURL is the destination the attempts-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 attemptsTargetURL = "https://93.184.216.34/hooks/attempts"
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"
// workingAttempts is the number of attempts a seeded target is
// already delivering with, which a rejected submission must not
// disturb.
workingAttempts = 2
)
// aboveCeiling is the smallest rejected whole number.
func aboveCeiling() string {
return strconv.Itoa(handlers.MaxTargetAttemptsForTest + 1)
}
// overCeilingAttempts is whole-number input past the limit, which is
// rejected with the limit named.
func overCeilingAttempts() []string {
return []string{aboveCeiling(), wayAboveCeiling}
}
// belowOneAttempts is whole-number input under the floor of one
// attempt, which is rejected with the floor named.
func belowOneAttempts() []string {
return []string{"0", "-5"}
}
// unparseableAttempts is input an operator can type into the field
// that is not a number of attempts. Each must be REJECTED: silently
// reading any of them as a default turns retries off on a
// store-and-forward proxy 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 unparseableAttempts() []string {
return []string{
notANumber,
"2.7",
"12345678901234567890",
"1e3",
}
}
// createAttemptsForm is a complete, otherwise-valid HTTP target
// creation, so the only thing any case below varies is max_attempts.
func createAttemptsForm(attempts string) url.Values {
form := url.Values{}
form.Set("name", "attempts-target")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", attemptsTargetURL)
if attempts != absentField {
form.Set("max_attempts", attempts)
}
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"
// absentAttempts 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 absentAttempts() []string {
return []string{absentField, "", " "}
}
// createWithAttempts posts the target create form for a fresh webhook
// and returns the webhook and the response.
func createWithAttempts(
t *testing.T,
env *sourceTestEnv,
attempts string,
) (database.Webhook, int, string) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets",
createAttemptsForm(attempts),
)
return webhook, w.Code, w.Body.String()
}
// TestTargetCreate_AttemptsAboveCeilingRejected 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_AttemptsAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
for _, attempts := range overCeilingAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%s should be rejected", attempts)
assert.Contains(t, body, ceiling,
"the rejection for %s should name the ceiling",
attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %s", attempts)
}
}
// TestTargetCreate_AttemptsBelowOneRejected proves the create form
// refuses 0 and less, naming the floor: every delivery is attempted
// at least once.
func TestTargetCreate_AttemptsBelowOneRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range belowOneAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%s should be rejected", attempts)
assert.Contains(t, body, "at least 1",
"the rejection for %s should name the floor",
attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %s", attempts)
}
}
// TestTargetCreate_UnparseableAttemptsRejected is the core of the
// defect: each of these was accepted with HTTP 200 and stored as a
// default.
func TestTargetCreate_UnparseableAttemptsRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range unparseableAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%q should be rejected, not coerced",
attempts)
assert.Contains(t, body, "whole number",
"the rejection for %q should say why", attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %q", attempts)
}
}
// TestTargetCreate_ValidAttemptsStored covers the accepting half,
// including both bounds: a bound that rejects its own limit would
// make the advertised range unreachable.
func TestTargetCreate_ValidAttemptsStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{1, 3, handlers.MaxTargetAttemptsForTest} {
webhook, code, body := createWithAttempts(
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].MaxAttempts)
}
}
// TestTargetCreate_AbsentAttemptsTakesDefault keeps the two cases
// distinct. An omitted field is not an operator asking for something
// invalid, so it still gets the default of one attempt rather than a
// 400 — otherwise the fix above would make the form unusable.
func TestTargetCreate_AbsentAttemptsTakesDefault(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range absentAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, 1, targets[0].MaxAttempts,
"an absent max_attempts should take the default")
}
}
// seedAttemptsTarget creates an HTTP target already delivering with
// workingAttempts attempts, through the real create handler.
func seedAttemptsTarget(
t *testing.T,
env *sourceTestEnv,
) (database.Webhook, database.Target) {
t.Helper()
webhook, code, body := createWithAttempts(
t, env, strconv.Itoa(workingAttempts),
)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
require.Equal(t, workingAttempts, targets[0].MaxAttempts)
return webhook, targets[0]
}
// editAttemptsForm 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_attempts alone.
func editAttemptsForm(attempts string) url.Values {
form := url.Values{}
form.Set("name", "renamed-by-edit")
form.Set("url", attemptsTargetURL)
if attempts != absentField {
form.Set("max_attempts", attempts)
}
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,
attempts, wantReason string,
) {
t.Helper()
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID, editAttemptsForm(attempts),
)
assert.Equal(t, http.StatusBadRequest, w.Code,
"max_attempts=%q should be rejected on edit", attempts)
assert.Contains(t, w.Body.String(), wantReason,
"the rejection for %q should say why", attempts)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, workingAttempts, stored.MaxAttempts,
"a rejected edit must not destroy the working number "+
"of attempts with %q", attempts)
assert.Equal(t, "attempts-target", stored.Name,
"a rejected edit must write nothing at all")
}
// TestTargetEdit_UnparseableAttemptsRejected is the damaging half of
// the defect. A target delivering with retries, re-saved with a typo
// in the field, returned 200 and was left with retries disabled and
// nothing said.
func TestTargetEdit_UnparseableAttemptsRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range unparseableAttempts() {
assertEditRejectedAndUnchanged(
t, env, attempts, "whole number",
)
}
}
// TestTargetEdit_AttemptsBelowOneRejected proves the floor applies
// to the edit path too.
func TestTargetEdit_AttemptsBelowOneRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range belowOneAttempts() {
assertEditRejectedAndUnchanged(t, env, attempts, "at least 1")
}
}
// TestTargetEdit_AttemptsAboveCeilingRejected proves the ceiling
// applies to the edit path too, naming itself, so the two paths
// cannot disagree about what is storable.
func TestTargetEdit_AttemptsAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
for _, attempts := range overCeilingAttempts() {
assertEditRejectedAndUnchanged(t, env, attempts, ceiling)
}
}
// TestTargetEdit_ValidAttemptsStored covers the accepting half of the
// edit path, so the bounds cannot be enforced by simply refusing
// every submission.
func TestTargetEdit_ValidAttemptsStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{1, 9, handlers.MaxTargetAttemptsForTest} {
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(strconv.Itoa(want)),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, want,
storedTarget(t, env, target.ID).MaxAttempts)
}
}
// TestTargetEdit_AbsentAttemptsLeavesStoredValue is the edit path's
// absent-versus-invalid case. The field is only offered by the forms
// for types that retry, so a submission without it must leave the
// stored count alone rather than be rejected or reset.
func TestTargetEdit_AbsentAttemptsLeavesStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range absentAttempts() {
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(attempts),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, workingAttempts,
storedTarget(t, env, target.ID).MaxAttempts,
"an absent max_attempts must leave the stored "+
"count alone (%q)", attempts)
}
}
// TestTargetAttempts_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 TestTargetAttempts_CreateAndEditAgreeOnEveryCase(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
accepted := []string{
"1", "2",
strconv.Itoa(handlers.MaxTargetAttemptsForTest),
}
overCeiling := overCeilingAttempts()
belowOne := belowOneAttempts()
unparseable := unparseableAttempts()
cases := make(
[]string, 0,
len(accepted)+len(overCeiling)+len(belowOne)+
len(unparseable),
)
cases = append(cases, accepted...)
cases = append(cases, overCeiling...)
cases = append(cases, belowOne...)
cases = append(cases, unparseable...)
for _, attempts := range cases {
_, createCode, _ := createWithAttempts(t, env, attempts)
webhook, target := seedAttemptsTarget(t, env)
editCode := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(attempts),
).Code
assert.Equal(t,
createCode == http.StatusBadRequest,
editCode == http.StatusBadRequest,
"create and edit must agree on max_attempts=%q "+
"(create %d, edit %d)",
attempts, createCode, editCode,
)
}
}
+17 -15
View File
@@ -105,6 +105,7 @@ func (h *Handlers) newTarget(
WebhookID: webhookID, WebhookID: webhookID,
Type: in.Type, Type: in.Type,
Active: true, Active: true,
MaxAttempts: 1,
} }
errMsg, err := h.setTargetFromForm(ctx, target, in) errMsg, err := h.setTargetFromForm(ctx, target, in)
@@ -117,9 +118,10 @@ func (h *Handlers) newTarget(
// setTargetFromForm validates a target form against the target's type // setTargetFromForm validates a target form against the target's type
// and, when it accepts it, sets the target's name, configuration and // and, when it accepts it, sets the target's name, configuration and
// retry count from it. It returns the message the form shows for // number of delivery attempts from it. It returns the message the form
// anything it refuses, an unknown type among them, and then leaves the // shows for anything it refuses, an unknown type among them, and then
// target unchanged; an error is the server's fault, as for newTarget. // leaves the target unchanged; an error is the server's fault, as for
// newTarget.
// The add target form and the target edit form both go through here, // The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be. // so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm( func (h *Handlers) setTargetFromForm(
@@ -136,20 +138,20 @@ func (h *Handlers) setTargetFromForm(
return errMsg, err return errMsg, err
} }
// An empty max_retries keeps the target's count: the // An empty max_attempts keeps the target's count: the default of
// fire-and-forget default of 0 for a new target, and the stored // 1 for a new target, and the stored count for an edited one,
// count for an edited one, since the forms for target types that // since the forms for target types that do not retry have no such
// do not retry have no such field. A value that is filled in but // field. A value that is filled in but invalid is refused rather
// invalid is refused rather than becoming that count, so a typo // than becoming that count, so a typo cannot destroy the count a
// cannot destroy the count a target is delivering with. // target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts)
if err != nil { if err != nil {
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil
} }
target.Name = in.Name target.Name = in.Name
target.Config = configJSON target.Config = configJSON
target.MaxRetries = maxRetries target.MaxAttempts = maxAttempts
return "", nil return "", nil
} }
@@ -176,8 +178,8 @@ type targetFormInput struct {
// ForwardQuery is an HTTP target's checkbox that passes each // ForwardQuery is an HTTP target's checkbox that passes each
// event's query string on to it. // event's query string on to it.
ForwardQuery bool ForwardQuery bool
// MaxRetries is an HTTP or Slack target's max_retries. // MaxAttempts is an HTTP or Slack target's max_attempts.
MaxRetries string MaxAttempts string
// Expiry is a database (archive) target's row expiry. // Expiry is a database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is a database (archive) target's rotation. // Rotation is a database (archive) target's rotation.
@@ -204,7 +206,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
Headers: r.PostFormValue("headers"), Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"), Timeout: r.PostFormValue("timeout"),
ForwardQuery: r.PostFormValue("forward_query") != "", ForwardQuery: r.PostFormValue("forward_query") != "",
MaxRetries: r.PostFormValue("max_retries"), MaxAttempts: r.PostFormValue("max_attempts"),
Expiry: r.PostFormValue("expiry"), Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"), Rotation: r.PostFormValue("rotation"),
} }
@@ -196,7 +196,7 @@ func TestHandleTargetCreate_QueryStringCannotSupplyNameOrType(
w, _ := postTargetCreate( w, _ := postTargetCreate(
t, env, webhook.ID, t, env, webhook.ID,
"name=leaky&type=slack&max_retries=9&expiry=30d&url="+ "name=leaky&type=slack&max_attempts=9&expiry=30d&url="+
url.QueryEscape(targetSecretURL), url.QueryEscape(targetSecretURL),
url.Values{}, url.Values{},
) )
+10 -10
View File
@@ -24,21 +24,21 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
targetType database.TargetType targetType database.TargetType
fields string fields string
wantConfig string wantConfig string
wantRetries int wantAttempts int
}{ }{
{ {
database.TargetTypeHTTP, "timeout=12&max_retries=3", database.TargetTypeHTTP, "timeout=12&max_attempts=3",
`{"url":"` + editOriginalURL + `","timeout":12}`, 3, `{"url":"` + editOriginalURL + `","timeout":12}`, 3,
}, },
{ {
database.TargetTypeSlack, "max_retries=4", database.TargetTypeSlack, "max_attempts=4",
`{"webhookUrl":"` + editOriginalURL + `"}`, 4, `{"webhookUrl":"` + editOriginalURL + `"}`, 4,
}, },
{ {
database.TargetTypeDatabase, "expiry=720h", database.TargetTypeDatabase, "expiry=720h",
`{"expiry":"720h"}`, 0, `{"expiry":"720h"}`, 1,
}, },
{database.TargetTypeLog, "", "", 0}, {database.TargetTypeLog, "", "", 1},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -62,7 +62,7 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, tc.targetType, targets[0].Type) assert.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries) assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts)
if tc.wantConfig == "" { if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config) assert.Empty(t, targets[0].Config)
@@ -90,11 +90,11 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeHTTP, database.TargetTypeHTTP,
"name=private&url=" + editBlockedURL + "name=private&url=" + editBlockedURL +
"&timeout=12&max_retries=3", "&timeout=12&max_attempts=3",
"Invalid target URL", "Invalid target URL",
}, },
{ {
database.TargetTypeSlack, "name=no-url&max_retries=4", database.TargetTypeSlack, "name=no-url&max_attempts=4",
"Webhook URL is required for Slack targets", "Webhook URL is required for Slack targets",
}, },
{ {
@@ -133,8 +133,8 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
assert.Contains(t, page, html.EscapeString(tc.reason)) assert.Contains(t, page, html.EscapeString(tc.reason))
// Each value comes back in a data attribute of the targets // Each value comes back in a data attribute of the targets
// section named after its field (max_retries as // section named after its field (max_attempts as
// data-max-retries), except url, which comes back in // data-max-attempts), except url, which comes back in
// data-destination; templates/source_detail.html says why. // data-destination; templates/source_detail.html says why.
for field := range typed { for field := range typed {
attr := "data-" + strings.ReplaceAll(field, "_", "-") attr := "data-" + strings.ReplaceAll(field, "_", "-")
+1 -1
View File
@@ -82,7 +82,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
Headers: cfg.Headers, Headers: cfg.Headers,
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery, ForwardQuery: cfg.ForwardQuery,
MaxRetries: strconv.Itoa(target.MaxRetries), MaxAttempts: strconv.Itoa(target.MaxAttempts),
Expiry: cfg.Expiry, Expiry: cfg.Expiry,
Rotation: cfg.Rotation, Rotation: cfg.Rotation,
} }
+14 -14
View File
@@ -117,7 +117,7 @@ func seedHTTPTarget(
form.Set("url", editOriginalURL) form.Set("url", editOriginalURL)
form.Set("headers", headers) form.Set("headers", headers)
form.Set("timeout", timeout) form.Set("timeout", timeout)
form.Set("max_retries", "3") form.Set("max_attempts", "3")
w := serveTarget( w := serveTarget(
env, http.MethodPost, env, http.MethodPost,
@@ -179,7 +179,7 @@ func editForm(targetURL, headers, timeout string) url.Values {
form.Set("url", targetURL) form.Set("url", targetURL)
form.Set("headers", headers) form.Set("headers", headers)
form.Set("timeout", timeout) form.Set("timeout", timeout)
form.Set("max_retries", "5") form.Set("max_attempts", "5")
return form return form
} }
@@ -280,9 +280,9 @@ func TestHandleTargetEditSubmit_Saves(t *testing.T) {
assertEditClearingRemovesThem(t, env) assertEditClearingRemovesThem(t, env)
}) })
t.Run("absent max_retries is not zeroed", func(t *testing.T) { t.Run("absent max_attempts is kept", func(t *testing.T) {
t.Parallel() t.Parallel()
assertEditKeepsAbsentMaxRetries(t, env) assertEditKeepsAbsentMaxAttempts(t, env)
}) })
} }
@@ -307,7 +307,7 @@ func assertEditChangesDestination(
reloaded := storedTarget(t, env, target.ID) reloaded := storedTarget(t, env, target.ID)
assert.Equal(t, "edited-name", reloaded.Name) assert.Equal(t, "edited-name", reloaded.Name)
assert.Equal(t, 5, reloaded.MaxRetries) assert.Equal(t, 5, reloaded.MaxAttempts)
assert.Equal( assert.Equal(
t, database.TargetTypeHTTP, reloaded.Type, t, database.TargetTypeHTTP, reloaded.Type,
"the edit form must not change a target's type", "the edit form must not change a target's type",
@@ -368,25 +368,25 @@ func assertEditClearingRemovesThem(
assert.Zero(t, cfg.Timeout) assert.Zero(t, cfg.Timeout)
} }
// Retries are offered only by the forms for target types that retry. // Delivery attempts are offered only by the forms for target types
// An absent field means the form does not edit retries, not that // that retry. An absent field means the form does not edit them, not
// they should be turned off. // that retries should be turned off.
func assertEditKeepsAbsentMaxRetries( func assertEditKeepsAbsentMaxAttempts(
t *testing.T, env *sourceTestEnv, t *testing.T, env *sourceTestEnv,
) { ) {
t.Helper() t.Helper()
webhook, target := seedHTTPTarget(t, env, "", "") webhook, target := seedHTTPTarget(t, env, "", "")
require.Equal(t, 3, target.MaxRetries) require.Equal(t, 3, target.MaxAttempts)
form := editForm(editOriginalURL, "", "") form := editForm(editOriginalURL, "", "")
form.Del("max_retries") form.Del("max_attempts")
w := submitTargetEdit(env, webhook.ID, target.ID, form) w := submitTargetEdit(env, webhook.ID, target.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal( assert.Equal(
t, 3, storedTarget(t, env, target.ID).MaxRetries, t, 3, storedTarget(t, env, target.ID).MaxAttempts,
) )
} }
@@ -659,12 +659,12 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeHTTP, database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL + "name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_retries=3", "&headers=X-Edited:+kept&timeout=12&max_attempts=3",
"Invalid target URL", "Invalid target URL",
}, },
{ {
database.TargetTypeSlack, database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25", "name=edited&url=" + editOriginalURL + "&max_attempts=25",
"Invalid delivery attempts", "Invalid delivery attempts",
}, },
{ {
-83
View File
@@ -1,83 +0,0 @@
package handlers
import (
"errors"
"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(
"must be a whole number",
)
// 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("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.
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) +
" attempts"
}
return errRetriesInvalid.Error() +
", or 0 for fire-and-forget"
}
-385
View File
@@ -1,385 +0,0 @@
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,
"/hook/"+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,
)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
stored := storedTarget(t, env, tgt.ID) stored := storedTarget(t, env, tgt.ID)
assert.False(t, stored.Active) assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name) assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxRetries) assert.Equal(t, 5, stored.MaxAttempts)
assert.Equal( assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL, t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
) )
+13 -15
View File
@@ -348,21 +348,19 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
) )
} }
// maxRetriesHelp is the wording both target forms must carry. The // maxAttemptsHelp is the wording both target forms must carry. The
// delivery core makes max_retries attempts in total, not that many // delivery core makes max_attempts attempts in total, not that many
// retries on top of a first try (a fresh delivery starts at attempt 1 // retries on top of a first try (a fresh delivery starts at attempt 1
// and target_http gives up once the attempt number reaches // and target_http gives up once the attempt number reaches
// max_retries), and 0 is special-cased to a single fire-and-forget // max_attempts).
// attempt with no circuit breaker. const maxAttemptsHelp = "How many times each delivery is attempted in " +
const maxRetriesHelp = "How many times each delivery is attempted in " + "all, the first attempt included."
"all, the first attempt included. 0 means a single attempt with no " +
"retries and no circuit breaker."
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries // TestTargetFormMaxAttemptsCopyMatchesBehaviour pins the max_attempts
// label, "Delivery attempts", and help text on both the create form // label, "Delivery attempts", and help text on both the create form
// (the add-target form on the webhook detail page) and the edit 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. // so the copy cannot drift back to calling the number a retry count.
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
var h *handlers.Handlers var h *handlers.Handlers
@@ -394,11 +392,11 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
assert.Contains(t, createBody, "Delivery attempts:</label>") assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains( assert.Contains(
t, createBody, maxRetriesHelp, t, createBody, maxAttemptsHelp,
"the add-target form must explain max_retries as total attempts", "the add-target form must explain max_attempts as total attempts",
) )
// A slack target exercises the same max_retries field while needing // A slack target exercises the same max_attempts field while needing
// only a URL from the edit template, so the test data stays // only a URL from the edit template, so the test data stays
// minimal. The Target and TargetForm keys mirror the field names // minimal. The Target and TargetForm keys mirror the field names
// the template reads off the handler's values. // the template reads off the handler's values.
@@ -413,7 +411,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
}, },
"TargetForm": map[string]any{ "TargetForm": map[string]any{
"URL": "https://hooks.slack.com/services/x", "URL": "https://hooks.slack.com/services/x",
"MaxRetries": "3", "MaxAttempts": "3",
}, },
dataKeyError: "", dataKeyError: "",
}, },
@@ -421,8 +419,8 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`) assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
assert.Contains( assert.Contains(
t, editBody, maxRetriesHelp, t, editBody, maxAttemptsHelp,
"the target edit form must explain max_retries as total attempts", "the target edit form must explain max_attempts as total attempts",
) )
} }
+20 -11
View File
@@ -10,6 +10,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/logfield"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
@@ -48,13 +49,14 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Logged only once the lookup has found the entrypoint, so // Logged only once the UUID is known to name a real
// an invented path writes no INFO line; a miss is logged at // entrypoint. The UUID comes straight out of the path on
// DEBUG. The entrypoint is named by its ID: the UUID is its // the one unauthenticated endpoint, so logging it before
// credential and is never logged. // the lookup let a client write an INFO line per invented
// path; the request itself is already in the access log
// and a miss is already logged at DEBUG.
h.log.Info("webhook request received", h.log.Info("webhook request received",
"entrypoint_id", entrypoint.ID, "entrypoint_uuid", entrypointUUID,
"webhook_id", entrypoint.WebhookID,
"method", r.Method, "method", r.Method,
"remoteIP", middleware.RemoteIP(r), "remoteIP", middleware.RemoteIP(r),
"clientIP", middleware.ClientIP(r), "clientIP", middleware.ClientIP(r),
@@ -131,10 +133,17 @@ func (h *Handlers) lookupEntrypoint(
"path = ?", entrypointUUID, "path = ?", entrypointUUID,
).First(&entrypoint) ).First(&entrypoint)
if result.Error != nil { if result.Error != nil {
// Not even an unknown UUID is logged. The receiver's log // The receiver is unauthenticated and /h/{uuid}
// lines never carry the path segment, which is a credential // matches any single segment, so this value is entirely
// whenever it names an entrypoint. // client-chosen on exactly the branch where the lookup
h.log.Debug("entrypoint not found") // failed. DEBUG is off by default; the cap is what keeps
// turning it on from restoring an unbounded write.
h.log.Debug(
"entrypoint not found",
"path", logfield.Truncate(
entrypointUUID, logfield.MaxBytes,
),
)
http.NotFound(w, r) http.NotFound(w, r)
return entrypoint, false return entrypoint, false
@@ -433,7 +442,7 @@ func buildDeliveryTasks(
TargetName: targets[i].Name, TargetName: targets[i].Name,
TargetType: targets[i].Type, TargetType: targets[i].Type,
TargetConfig: targets[i].Config, TargetConfig: targets[i].Config,
MaxRetries: targets[i].MaxRetries, MaxAttempts: targets[i].MaxAttempts,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
-84
View File
@@ -12,15 +12,12 @@ import (
"testing" "testing"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
// TestHandleWebhook_LogsClientNextToThePeer checks that the // TestHandleWebhook_LogsClientNextToThePeer checks that the
@@ -102,87 +99,6 @@ func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
} }
} }
// TestHandleWebhook_LogsNoEntrypointUUID sends an accepted event, a
// request over the receiver rate limit and a request for an unknown
// UUID through the receiver as routes.go mounts it, and checks that
// nothing logged, at any level, carries either UUID.
func TestHandleWebhook_LogsNoEntrypointUUID(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
cfg := &config.Config{DataDir: t.TempDir(), ReceiverRateLimit: 1}
app := newTestAppWithConfig(t, cfg, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
buf := new(bytes.Buffer)
log := slog.New(slog.NewJSONHandler(
buf, &slog.HandlerOptions{Level: slog.LevelDebug},
))
h.SetLogForTest(log)
mw := middlewaretest.New(t, log, cfg, nil)
webhook := seedWebhook(t, db)
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Active: true,
}
require.NoError(t, db.DB().Omit(clause.Associations).
Create(entrypoint).Error)
unknownUUID := uuid.New().String()
router := chi.NewRouter()
router.Use(mw.Logging())
router.With(mw.ReceiverRateLimit()).HandleFunc(
middleware.ReceiverRoute, h.HandleWebhook(),
)
// In order: the limit is one request per minute per path, so the
// second request for the known UUID is the rejected one.
for _, step := range []struct {
entrypointUUID string
wantStatus int
}{
{entrypoint.Path, http.StatusOK},
{entrypoint.Path, http.StatusTooManyRequests},
{unknownUUID, http.StatusNotFound},
} {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/"+step.entrypointUUID, strings.NewReader("{}"),
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, step.wantStatus, w.Code)
}
// The lines this test is about were all written, so the checks
// below are not passing on an empty log.
for _, msg := range []string{
"webhook request received",
"webhook receiver rate limit exceeded",
"entrypoint not found",
} {
assert.Contains(t, buf.String(), `"msg":"`+msg+`"`)
}
assert.NotContains(t, buf.String(), entrypoint.Path)
assert.NotContains(t, buf.String(), unknownUUID)
line := receivedLine(t, buf)
assert.Equal(t, entrypoint.ID, line["entrypoint_id"])
assert.Equal(t, webhook.ID, line["webhook_id"])
}
// receivedLine returns the one "webhook request received" line in the // receivedLine returns the one "webhook request received" line in the
// captured JSON log. // captured JSON log.
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any { func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
+14 -122
View File
@@ -7,7 +7,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -28,13 +27,6 @@ const floodRequests = 64
// line for a redirected or rejected request may contain it. // line for a redirected or rejected request may contain it.
const attackerMarker = "QQATTACKERTEXTQQ" const attackerMarker = "QQATTACKERTEXTQQ"
// knownEntrypointUUID and failingEntrypointUUID are the UUIDs the
// stand-in receiver in accessLogRouter answers 200 and 500 to.
const (
knownEntrypointUUID = "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60"
failingEntrypointUUID = "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35"
)
// maxLineBytes bounds a single access log line whose client-supplied // maxLineBytes bounds a single access log line whose client-supplied
// fields are of ordinary size. Well above what the fixed fields need, // fields are of ordinary size. Well above what the fixed fields need,
// well below the length of the oversized input the amplification tests // well below the length of the oversized input the amplification tests
@@ -140,21 +132,17 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
) )
router.HandleFunc( router.HandleFunc(
middleware.ReceiverRoute, "/h/{uuid}",
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
// Stands in for the real handler: a known entrypoint // Stands in for the real handler: an unknown entrypoint
// UUID succeeds, a failing one is a server error, and // UUID 404s, a known one succeeds.
// any other 404s. if chi.URLParam(r, "uuid") != "known" {
switch chi.URLParam(r, "uuid") {
case knownEntrypointUUID:
w.WriteHeader(http.StatusOK)
case failingEntrypointUUID:
http.Error(
w, "boom", http.StatusInternalServerError,
)
default:
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return
} }
w.WriteHeader(http.StatusOK)
}, },
) )
@@ -347,13 +335,11 @@ func oversizedValue(ch string) string {
} }
// oversizedHeaders fills every client-supplied header the access log // oversizedHeaders fills every client-supplied header the access log
// reads with the same value. The Referer carries it as the host of an // reads with the same value.
// absolute URL, because the access log keeps only a Referer's scheme
// and host.
func oversizedHeaders(value string) map[string]string { func oversizedHeaders(value string) map[string]string {
return map[string]string{ return map[string]string{
"User-Agent": value, "User-Agent": value,
"Referer": "http://" + value, "Referer": value,
"X-Request-Id": value, "X-Request-Id": value,
} }
} }
@@ -663,111 +649,17 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
router := accessLogRouter(m) router := accessLogRouter(m)
assert.Equal( assert.Equal(
t, http.StatusOK, t, http.StatusOK, get(t, router, "/h/known?src=ci"),
get(t, router, "/.well-known/healthcheck?src=ci"),
) )
// The path resolved against a stored entrypoint, so it stays. The
// query never does: see TestAccessLog_UnauthenticatedSuccess...
entries := accessLogEntries(t, buf) entries := accessLogEntries(t, buf)
require.Len(t, entries, 1) require.Len(t, entries, 1)
assert.Equal( assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
t, "/.well-known/healthcheck?(redacted)", entries[0]["url"],
)
assert.NotContains(t, buf.String(), "src=ci") assert.NotContains(t, buf.String(), "src=ci")
} }
// TestAccessLog_ReceiverNeverLogsTheUUID covers the two statuses that
// keep the concrete path on every other route. On the receiver that
// path carries the entrypoint UUID, so the line names the route
// pattern instead.
func TestAccessLog_ReceiverNeverLogsTheUUID(t *testing.T) {
t.Parallel()
for entrypointUUID, wantStatus := range map[string]int{
knownEntrypointUUID: http.StatusOK,
failingEntrypointUUID: http.StatusInternalServerError,
} {
t.Run(strconv.Itoa(wantStatus), func(t *testing.T) {
t.Parallel()
m, buf := capturingMiddleware(t)
router := accessLogRouter(m)
assert.Equal(
t, wantStatus,
get(t, router, "/h/"+entrypointUUID+"?src=ci"),
)
entries := accessLogEntries(t, buf)
require.Len(t, entries, 1)
assert.Equal(t, middleware.ReceiverRoute, entries[0]["url"])
assert.NotContains(t, buf.String(), entrypointUUID)
})
}
}
// TestAccessLog_ReceiverPreflightNeverLogsTheUUID sends a CORS
// preflight to the receiver in dev, in the production middleware
// order. CORS answers it with 200 before chi routes it, so the line
// names no route and carries no UUID.
func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) {
t.Parallel()
m, buf := capturingMiddleware(t)
router := chi.NewRouter()
router.Use(m.Logging())
router.Use(m.CORS())
router.HandleFunc(
middleware.ReceiverRoute,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodOptions,
"/h/"+knownEntrypointUUID, nil,
)
req.Header.Set("Origin", "https://sender.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
entries := accessLogEntries(t, buf)
require.Len(t, entries, 1)
assert.Equal(t, unmatchedRouteLiteral, entries[0]["url"])
assert.NotContains(t, buf.String(), knownEntrypointUUID)
}
// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose
// Referer is the entrypoint URL, as a sender's is after it follows a
// redirect from http:// to https://. The referer field keeps only the
// scheme and host, and a Referer without them is logged as a marker.
func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) {
t.Parallel()
m, buf := capturingMiddleware(t)
router := accessLogRouter(m)
for _, referer := range []string{
"http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci",
"/h/" + knownEntrypointUUID,
} {
assert.Equal(t, http.StatusOK, getWithHeaders(
t, router, "/h/"+knownEntrypointUUID,
map[string]string{"Referer": referer},
))
}
entries := accessLogEntries(t, buf)
require.Len(t, entries, 2)
assert.Equal(t, "http://webhooker.example", entries[0]["referer"])
assert.Equal(t, "(redacted)", entries[1]["referer"])
assert.NotContains(t, buf.String(), knownEntrypointUUID)
}
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) { func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
t.Parallel() t.Parallel()
+6 -12
View File
@@ -10,10 +10,8 @@ package middleware_test
// the client's choosing. // the client's choosing.
// - CSRF's 403 rejection, at WARN, also registered ahead of // - CSRF's 403 rejection, at WARN, also registered ahead of
// RequireAuth. // RequireAuth.
// - The rate limiters' 429 rejection, at WARN. On the receiver // - The rate limiters' 429 rejection, at WARN, on the
// route it names the route pattern rather than the path, and // unauthenticated receiver among others.
// the limiters that log a path sit behind RequireAuth, so its cap
// is defensive.
// - RequireAuth's own unauthenticated-request line, at DEBUG. // - RequireAuth's own unauthenticated-request line, at DEBUG.
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is // - RecordLoginFailure's throttle rejection, at WARN. Its cap is
// defensive rather than load-bearing today: chi pins the one // defensive rather than load-bearing today: chi pins the one
@@ -270,14 +268,10 @@ func logSites() map[string]logSite {
send: postNoToken, send: postNoToken,
wantStatus: http.StatusForbidden, wantStatus: http.StatusForbidden,
}, },
// The 429 line every limiter but the aggregate one shares. // The per-entrypoint receiver limiter, unauthenticated. Its
// Mounted outside any route, as here, it logs the path, as // bucket is keyed on the path, so the first request through a
// it does for the limiters behind RequireAuth; on // fresh path is served and only the ones after it are
// ReceiverRoute it names the pattern instead. The // rejected; sendUntilLimited absorbs that.
// per-entrypoint receiver limiter's bucket is keyed on the
// path, so the first request through a fresh path is served
// and only the ones after it are rejected; sendUntilLimited
// absorbs that.
"receiver rate limit 429": { "receiver rate limit 429": {
build: func( build: func(
t *testing.T, m *middleware.Middleware, t *testing.T, m *middleware.Middleware,
+1 -2
View File
@@ -41,8 +41,7 @@ const unmatchedMethod = unmatchedRoute
// after /h/ is client-chosen, so labelling by path lets any // after /h/ is client-chosen, so labelling by path lets any
// unauthenticated client mint permanent series at will, and publishes // unauthenticated client mint permanent series at will, and publishes
// the entrypoint UUID (the receiver's only credential) in the scrape // the entrypoint UUID (the receiver's only credential) in the scrape
// while doing it. The access log and the 429 line read it for the // while doing it.
// same reason: see ReceiverRoute.
// //
// chi populates the route context during routeHTTP, so this is only // chi populates the route context during routeHTTP, so this is only
// valid once routing has run. Every caller below is on the recording // valid once routing has run. Every caller below is on the recording
+39 -52
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
"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"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@@ -22,7 +23,6 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/referer"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -96,14 +96,14 @@ const (
// It is also the ceiling on every OTHER line this service writes // It is also the ceiling on every OTHER line this service writes
// THROUGH SLOG that carries text an UNAUTHENTICATED client // THROUGH SLOG that carries text an UNAUTHENTICATED client
// supplies. Those lines — the MaxBodySize rejection, the CSRF // supplies. Those lines — the MaxBodySize rejection, the CSRF
// rejection, the unauthenticated-request DEBUG line, the // rejection, the rate-limit rejection, the unauthenticated-request
// failed-login DEBUG lines, and the two login-throttle WARN // and unknown-entrypoint DEBUG lines, the failed-login DEBUG
// lines ("login failure limit exceeded" in loginguard.go and // lines, and the two login-throttle WARN lines ("login failure
// "password verification capacity exhausted" in // limit exceeded" in loginguard.go and "password verification
// internal/handlers/auth.go) — spend the same per-field budgets, // capacity exhausted" in internal/handlers/auth.go) — spend the
// and each carries strictly fewer client-supplied fields than the // same per-field budgets, and each carries strictly fewer
// access log does, so none of them can reach a width the access // client-supplied fields than the access log does,
// log cannot. That is // so none of them can reach a width the access log cannot. That is
// asserted directly, per line and under both handlers, rather than // asserted directly, per line and under both handlers, rather than
// left to the reasoning: see logbound_test.go in this package and // left to the reasoning: see logbound_test.go in this package and
// in internal/handlers. // in internal/handlers.
@@ -151,12 +151,6 @@ const (
MaxAccessLogLineBytes = 2560 MaxAccessLogLineBytes = 2560
) )
// ReceiverRoute is the route pattern the webhook receiver is served
// at. Its path carries the entrypoint UUID, which is the receiver's
// only credential, so the log lines this package writes name a
// request on this route by the pattern, never by its path.
const ReceiverRoute = "/h/{uuid}"
//nolint:revive // MiddlewareParams is a standard fx naming convention. //nolint:revive // MiddlewareParams is a standard fx naming convention.
type MiddlewareParams struct { type MiddlewareParams struct {
fx.In fx.In
@@ -274,14 +268,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// marker. // marker.
// //
// The path on those branches is bounded by the service's routes or by // The path on those branches is bounded by the service's routes or by
// the operator's data — a 2xx under /s means the file is in the // the operator's data — a 2xx on the receiver means the UUID named a
// embedded tree. The query is not bounded by anything: // stored entrypoint, a 2xx under /s means the file is in the embedded
// /.well-known/healthcheck and /s/* take no authentication and sit // tree. The query is not bounded by anything: /.well-known/healthcheck
// behind no rate limiter, and /pages/login behind only the login // and /s/* take no authentication and sit behind no rate limiter, and
// limiter, so any of them will answer 200 to a URL carrying an // /pages/login behind only the login limiter, so any of them will
// arbitrary number of arbitrary bytes after the '?'. Keeping the path // answer 200 to a URL carrying an arbitrary number of arbitrary bytes
// and dropping the query is what makes this branch as bounded as the // after the '?'. Keeping the path and dropping the query is what makes
// pattern branches below. // this branch as bounded as the pattern branches below.
// //
// Nothing debuggable is lost. The only query parameters the service // Nothing debuggable is lost. The only query parameters the service
// reads are the login page's `next`, the page to return to, // reads are the login page's `next`, the page to return to,
@@ -303,41 +297,37 @@ func concreteLogURL(r *http.Request) string {
// accessLogURL returns the value for the access log's url field. // accessLogURL returns the value for the access log's url field.
// //
// On ReceiverRoute it is the route pattern whatever the status, so // 2xx and 5xx responses get the concrete path (see concreteLogURL). A
// the entrypoint UUID in the path is never logged. A request chi never // success resolved against a static route or against the operator's
// routed gets unmatchedRoute whatever the status, for the same reason: // own data — on the receiver, a 2xx means the UUID named a stored
// in dev, CORS answers a preflight to the receiver before routing. // entrypoint — and a server error is our own bug, where the exact URL
// // is the primary evidence and which no client can provoke at will.
// On every other route, 2xx and 5xx responses get the concrete path
// (see concreteLogURL). A success resolved against a static route or
// against the operator's own data, and a server error is our own bug,
// where the exact URL is the primary evidence and which no client can
// provoke at will.
// //
// 3xx and 4xx responses get the chi route pattern instead. Those are // 3xx and 4xx responses get the chi route pattern instead. Those are
// the outcomes an unauthenticated client drives for free, such as a // the outcomes an unauthenticated client drives for free: 404 or 429
// 303 to the login page on any invented /user/ path. Logging the // on any invented /h/ path, 303 to the login page on any
// concrete URL there lets a flood write attacker-chosen text, of // invented /user/ path. Logging the concrete URL there lets a flood
// attacker-chosen length, into the operator's log at one line per // write attacker-chosen text, of attacker-chosen length, into the
// request. The pattern comes from the router's own table, so it is // operator's log at one line per request. The pattern comes from the
// bounded by the service's routes while still naming which class of // router's own table, so it is bounded by the service's routes while
// request was rejected. // still naming which class of request was rejected.
// //
// The pattern is only populated once routing has run, so this must be // The pattern is only populated once routing has run, so this must be
// called after the handler returns, not before. // called after the handler returns, not before.
func accessLogURL(r *http.Request, status int) string { func accessLogURL(r *http.Request, status int) string {
pattern := routePatternID(r.Context())
if pattern == ReceiverRoute || pattern == unmatchedRoute {
return pattern
}
if status < http.StatusMultipleChoices || if status < http.StatusMultipleChoices ||
status >= http.StatusInternalServerError { status >= http.StatusInternalServerError {
return concreteLogURL(r) return concreteLogURL(r)
} }
if rc := chi.RouteContext(r.Context()); rc != nil {
if pattern := rc.RoutePattern(); pattern != "" {
return pattern return pattern
} }
}
return unmatchedRoute
}
// Logging returns middleware that logs each HTTP request with // Logging returns middleware that logs each HTTP request with
// timing and metadata. // timing and metadata.
@@ -389,8 +379,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
requestID, maxLogRequestIDBytes, requestID, maxLogRequestIDBytes,
), ),
"referer", logfield.Truncate( "referer", logfield.Truncate(
referer.SchemeAndHost(r.Referer()), r.Referer(), logfield.MaxBytes,
logfield.MaxBytes,
), ),
"proto", r.Proto, "proto", r.Proto,
"remoteIP", RemoteIP(r), "remoteIP", RemoteIP(r),
@@ -549,9 +538,7 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
// SecurityHeaders returns middleware that sets production security // SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero), // headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and // X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
// Permissions-Policy. The CSP takes scripts and styles only from // Permissions-Policy.
// files on this origin, so a template may hold no inline script,
// event handler or style attribute.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler { func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
maxAge := s.params.Config.HSTSMaxAge maxAge := s.params.Config.HSTSMaxAge
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) + hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
@@ -573,8 +560,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
w.Header().Set( w.Header().Set(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; "+ "default-src 'self'; "+
"script-src 'self'; "+ "script-src 'self' 'unsafe-inline'; "+
"style-src 'self'", "style-src 'self' 'unsafe-inline'",
) )
w.Header().Set( w.Header().Set(
"Referrer-Policy", "Referrer-Policy",
+6 -23
View File
@@ -747,10 +747,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
// --- SecurityHeaders Middleware Tests --- // --- SecurityHeaders Middleware Tests ---
// securityHeaders serves one request through SecurityHeaders built // hstsHeaders serves one request through SecurityHeaders built over
// over a Config with the given HSTSMaxAge, and returns the response's // a Config with the given HSTSMaxAge, and returns every
// headers. // Strict-Transport-Security value on the response.
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header { func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
t.Helper() t.Helper()
cfg := &config.Config{HSTSMaxAge: maxAge} cfg := &config.Config{HSTSMaxAge: maxAge}
@@ -772,7 +772,7 @@ func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
return w.Header() return w.Header().Values("Strict-Transport-Security")
} }
func TestSecurityHeaders_HSTS(t *testing.T) { func TestSecurityHeaders_HSTS(t *testing.T) {
@@ -804,28 +804,11 @@ func TestSecurityHeaders_HSTS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal( assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
t, tt.expected,
securityHeaders(t, tt.maxAge).Values(
"Strict-Transport-Security",
),
)
}) })
} }
} }
// TestSecurityHeaders_CSP pins the Content-Security-Policy: a page
// takes its scripts and styles only from files on its own origin,
// never from an inline script, event handler or style.
func TestSecurityHeaders_CSP(t *testing.T) {
t.Parallel()
assert.Equal(
t, "default-src 'self'; script-src 'self'; style-src 'self'",
securityHeaders(t, 0).Get("Content-Security-Policy"),
)
}
// --- MaxBodySize Middleware Tests --- // --- MaxBodySize Middleware Tests ---
const testBodyLimit int64 = 64 const testBodyLimit int64 = 64
+10 -12
View File
@@ -259,28 +259,26 @@ func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
return peer, true return peer, true
} }
// tooManyRequests returns the 429 handler used by the postRateLimit // tooManyRequests returns the 429 handler used by the
// limiters and the per-entrypoint receiver limiter: it logs the // password-change and per-entrypoint receiver limiters: it logs the
// rejection with logMessage and answers with responseMessage. // rejection with logMessage and answers with responseMessage.
// httprate adds the Retry-After header (RFC 6585). The aggregate // httprate adds the Retry-After header (RFC 6585). The aggregate
// receiver limiter uses floodTooManyRequests instead. // receiver limiter uses floodTooManyRequests instead.
// //
// On ReceiverRoute the line names the route pattern, so the // The path is capped against the same budget as the access log's url
// entrypoint UUID in the path is never logged. Elsewhere it names the // field. The per-entrypoint receiver limiter is unauthenticated and
// path, capped against the same budget as the access log's url field; // its path is a client-chosen segment of client-chosen length, so at
// the limiters there sit behind RequireAuth, so the cap is defensive. // WARN an uncapped path would let a sender pick the size of the line
// it writes — the same defect the access log capping closed.
func (m *Middleware) tooManyRequests( func (m *Middleware) tooManyRequests(
logMessage, responseMessage string, logMessage, responseMessage string,
) http.HandlerFunc { ) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if routePatternID(r.Context()) == ReceiverRoute {
path = ReceiverRoute
}
m.log.Warn( m.log.Warn(
logMessage, logMessage,
"path", logfield.Truncate(path, logfield.MaxBytes), "path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes,
),
"remoteIP", RemoteIP(r), "remoteIP", RemoteIP(r),
"clientIP", ClientIP(r), "clientIP", ClientIP(r),
) )
-32
View File
@@ -1,32 +0,0 @@
// Package referer reduces a Referer header to its scheme and host, so
// that a URL path, which on the receiver route carries the entrypoint
// UUID, never reaches a log line or an error report.
package referer
import "net/url"
// redacted stands in for a Referer that cannot be reduced to its
// scheme and host.
const redacted = "(redacted)"
// SchemeAndHost returns a Referer's scheme and host, never its path or
// query. The path can be an entrypoint URL, which carries the UUID: a
// sender that follows a redirect from http:// to https:// sends the
// URL it was redirected from, and a browser sends the 404 page's URL
// under /h/ when it fetches that page's stylesheet. A Referer without
// both a scheme and a host comes back as "(redacted)".
//
// It is the access log's referer field, and the Referer header the
// Sentry hook sends.
func SchemeAndHost(referer string) string {
if referer == "" {
return ""
}
parsed, err := url.Parse(referer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return redacted
}
return parsed.Scheme + "://" + parsed.Host
}
-67
View File
@@ -1,67 +0,0 @@
package referer_test
import (
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/referer"
)
// entrypointUUID is the secret part of an entrypoint URL; no case may
// return it.
const entrypointUUID = "0b6f7d3e-6a39-4e3c-9f0a-2c4d8e1b5a77"
// redacted is what SchemeAndHost returns for a Referer it cannot
// reduce to a scheme and host.
const redacted = "(redacted)"
func TestSchemeAndHost(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
referer string
want string
}{
{
name: "no Referer",
referer: "",
want: "",
},
{
name: "entrypoint URL with a query",
referer: "http://webhooker.example/h/" + entrypointUUID +
"?src=ci",
want: "http://webhooker.example",
},
{
name: "host with a port",
referer: "https://webhooker.example:8443/h/" + entrypointUUID,
want: "https://webhooker.example:8443",
},
{
name: "path without a scheme or host",
referer: "/h/" + entrypointUUID,
want: redacted,
},
{
name: "host without a scheme",
referer: "webhooker.example/h/" + entrypointUUID,
want: redacted,
},
{
name: "not a URL",
referer: "http://[::1/h/" + entrypointUUID,
want: redacted,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := referer.SchemeAndHost(tc.referer)
assert.Equal(t, tc.want, got)
assert.NotContains(t, got, entrypointUUID)
})
}
}
+5 -86
View File
@@ -60,8 +60,8 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the // TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so // event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages' // under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives, the copy control and the delete prompts work, // Alpine.js directives and the copy control work, and that a target's
// and that a target's row shows its delivery counts. // row shows its delivery counts.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -74,10 +74,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook, older, event, target := seedBrowserWebhook(t, env, userID) webhook, older, event, target := seedBrowserWebhook(t, env, userID)
// The sign-in page is loaded before the browser signs in, since a
// signed-in visit to it is sent on to another page.
checkOtherPages(ctx, t, srv.URL+"/pages/login")
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")), ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
)) ))
@@ -103,11 +99,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name) checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
checkOtherPages(ctx, t,
srv.URL+"/hooks", srv.URL+"/settings", srv.URL+"/user/browser",
// A path that does not exist, which gets the 404 error page.
srv.URL+"/no-such-page")
checkDeletePrompt(ctx, t, page, target)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
} }
@@ -363,11 +354,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
}{ }{
{ {
"http", "http", "http", "http",
"csrf_token name type url headers timeout max_retries", "csrf_token name type url headers timeout max_attempts",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"slack", "slack", "csrf_token name type url max_retries", "slack", "slack", "csrf_token name type url max_attempts",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
@@ -643,7 +634,7 @@ func checkRefusedEdits(
"#url": "http://127.0.0.1/hook", "#url": "http://127.0.0.1/hook",
"#headers": "X-Edited: kept", "#headers": "X-Edited: kept",
"#timeout": "12", "#timeout": "12",
"#max_retries": "3", "#max_attempts": "3",
}}, }},
{page + "/edit", map[string]string{ {page + "/edit", map[string]string{
"#name": "edited-webhook", "#name": "edited-webhook",
@@ -1397,75 +1388,3 @@ func checkPhoneWidth(
assert.False(t, sideways, "the event log scrolls sideways on a phone") assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone") assert.Empty(t, cutOff, "the event log cuts these off on a phone")
} }
// checkOtherPages loads the pages at urls, which no other check opens,
// so that anything the browser reports going wrong on them fails the
// test too.
func checkOtherPages(ctx context.Context, t *testing.T, urls ...string) {
t.Helper()
for _, url := range urls {
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
}
}
// answerPrompt returns a script that stands in for the user at the
// browser's prompt: it makes window.confirm record the question in
// window.asked and give answer. It also records in window.sent whether
// a form is sent once its submit event is handled, from a listener on
// window, which the event reaches after the listener static/js/app.js
// adds to document.
func answerPrompt(answer bool) string {
return fmt.Sprintf(`
window.asked = "";
window.sent = false;
window.confirm = (question) => {
window.asked = question;
return %t;
};
window.addEventListener("submit", (event) => {
window.sent = !event.defaultPrevented;
});`, answer)
}
// checkDeletePrompt loads a webhook page and clicks Delete beside
// target. It checks that the browser asks first, naming the target,
// that answering no leaves the form unsent, and that answering yes
// sends it and the target is deleted.
func checkDeletePrompt(
ctx context.Context, t *testing.T, url string, target *database.Target,
) {
t.Helper()
form := `form[action$="/targets/` + target.ID + `/delete"]`
question := `Delete target "` + target.Name + `"?` + "\n\n" +
"Nothing more is delivered to it. " +
"Its past deliveries stay in the event log."
var (
asked string
sent bool
listed bool
)
require.NoError(t, chromedp.Run(
ctx, loadPage(url), chromedp.Evaluate(answerPrompt(false), nil),
))
click(ctx, t, form+" button")
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(`window.asked`, &asked),
chromedp.Evaluate(`window.sent`, &sent),
))
assert.Equal(t, question, asked, "Delete does not ask about the target")
assert.False(t, sent, "answering no still sends the delete form")
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(answerPrompt(true), nil),
))
clickAndLoad(ctx, t, form+" button")
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
`document.querySelector('`+form+`') !== null`, &listed,
)))
assert.False(t, listed, "answering yes does not delete the target")
}
-77
View File
@@ -5,7 +5,6 @@ import (
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"syscall"
"testing" "testing"
"time" "time"
@@ -26,11 +25,6 @@ const (
// its network namespace even with a published port. // its network namespace even with a published port.
wildcardV4 = "0.0.0.0" wildcardV4 = "0.0.0.0"
// loopbackV6 and wildcardV6 are the IPv6 counterparts of the two
// above.
loopbackV6 = "::1"
wildcardV6 = "::"
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a // unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
// well-formed literal that no host is assigned, so binding it // well-formed literal that no host is assigned, so binding it
// fails with EADDRNOTAVAIL rather than succeeding somewhere // fails with EADDRNOTAVAIL rather than succeeding somewhere
@@ -122,24 +116,6 @@ func otherLocalAddr(t *testing.T) string {
return "" return ""
} }
// skipWithoutIPv6 skips the test when this host cannot bind
// loopbackV6. Without IPv6 there is no second family for a bind to
// claim or leave alone.
func skipWithoutIPv6(t *testing.T) {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(
t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"),
)
if err != nil {
t.Skip("host has no IPv6 loopback to bind")
}
require.NoError(t, l.Close())
}
// startBoundServer starts the wired app with the given bind address // startBoundServer starts the wired app with the given bind address
// on a free port and returns that port. The app is stopped on // on a free port and returns that port. The app is stopped on
// cleanup. // cleanup.
@@ -316,59 +292,6 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) {
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port))) requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
} }
// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on
// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or
// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a
// socket that answers IPv6 too unless told otherwise, which put the
// admin UI and the receiver on every IPv6 address of a host whose
// operator had chosen an IPv4 bind.
func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) {
t.Parallel()
skipWithoutIPv6(t)
for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} {
t.Run(bindAddress, func(t *testing.T) {
t.Parallel()
port := startBoundServer(t, bindAddress)
// Positive control: the service really is up, on IPv4.
requireDialable(
t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)),
)
dialer := net.Dialer{Timeout: dialTimeout}
conn, err := dialer.DialContext(
t.Context(), "tcp",
net.JoinHostPort(loopbackV6, strconv.Itoa(port)),
)
if err == nil {
_ = conn.Close()
}
require.ErrorIs(
t, err, syscall.ECONNREFUSED,
"a %s bind answered on %s", bindAddress, loopbackV6,
)
})
}
}
// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart:
// :: is the value that binds IPv6, and it answers IPv4 as well.
func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) {
t.Parallel()
skipWithoutIPv6(t)
port := startBoundServer(t, wildcardV6)
requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port)))
requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)))
}
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound // TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
// listener serves the application rather than merely accepting TCP, // listener serves the application rather than merely accepting TCP,
// so a bind address that is honoured cannot be mistaken for one that // so a bind address that is honoured cannot be mistaken for one that
+1 -30
View File
@@ -1,11 +1,9 @@
package server package server
import ( import (
"context"
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/netip"
"strconv" "strconv"
"time" "time"
) )
@@ -41,23 +39,6 @@ func (s *Server) listenAddr() string {
) )
} }
// listenNetwork returns the network the HTTP listener binds on, "tcp4"
// for an IPv4 BindAddress and "tcp" for any other.
//
// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well,
// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what
// the README says it means. :: stays on "tcp" and answers both. An
// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it
// carries to Go's listener, so it gets "tcp4" too.
func (s *Server) listenNetwork() string {
addr, err := netip.ParseAddr(s.params.Config.BindAddress)
if err == nil && addr.Unmap().Is4() {
return "tcp4"
}
return "tcp"
}
// newHTTPServer builds the HTTP server for this Server's // newHTTPServer builds the HTTP server for this Server's
// configuration. // configuration.
// //
@@ -86,17 +67,7 @@ func (s *Server) serveUntilShutdown() {
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr) s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
// ListenAndServe always listens on "tcp", which would undo err := s.httpServer.ListenAndServe()
// listenNetwork.
var listenCfg net.ListenConfig
listener, err := listenCfg.Listen(
context.Background(), s.listenNetwork(), s.httpServer.Addr,
)
if err == nil {
err = s.httpServer.Serve(listener)
}
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
s.shutdownWithFailure() s.shutdownWithFailure()
+3 -4
View File
@@ -6,8 +6,7 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
chimw "github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/static" "sneak.berlin/go/webhooker/static"
) )
@@ -61,7 +60,7 @@ func (s *Server) SetupRoutes() {
} }
func (s *Server) setupGlobalMiddleware() { func (s *Server) setupGlobalMiddleware() {
s.router.Use(chimw.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging()) s.router.Use(s.mw.Logging())
@@ -346,7 +345,7 @@ func (s *Server) setupWebhookRoutes() {
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused // unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
// pins it. // pins it.
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
middleware.ReceiverRoute, "/h/{uuid}",
s.h.HandleWebhook(), s.h.HandleWebhook(),
) )
} }
+7 -18
View File
@@ -8,7 +8,6 @@ import (
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/referer"
) )
// sentryRedacted stands in for a withheld field on every event shipped // sentryRedacted stands in for a withheld field on every event shipped
@@ -254,7 +253,6 @@ func sentryTransactionName(name, pattern string) string {
// gorilla/csrf accepts in place of the form field — and the shared // gorilla/csrf accepts in place of the form field — and the shared
// secrets senders put on the receiver route (X-Gitlab-Token and the // secrets senders put on the receiver route (X-Gitlab-Token and the
// per-provider signature headers) would otherwise ship verbatim. // per-provider signature headers) would otherwise ship verbatim.
// The Referer it keeps is cut down to its scheme and host.
func keptSentryHeaders(headers map[string]string) map[string]string { func keptSentryHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 { if len(headers) == 0 {
return headers return headers
@@ -263,30 +261,21 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
kept := make(map[string]string, len(headers)) kept := make(map[string]string, len(headers))
for name, value := range headers { for name, value := range headers {
if !sentryKeepsHeader(name) { if sentryKeepsHeader(name) {
continue
}
if http.CanonicalHeaderKey(name) == "Referer" {
value = referer.SchemeAndHost(value)
}
kept[name] = value kept[name] = value
} }
}
return kept return kept
} }
// sentryKeepsHeader reports whether a request header is routing or // sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept // content metadata rather than client-chosen payload. Referer is kept
// only as its scheme and host, through referer.SchemeAndHost, which // on the reasoning that it is browser-set, that the only query
// the access log uses too: its path can be an entrypoint URL, which // parameters in this service's own URLs are the login page's `next`,
// carries the UUID, as when a sender follows a redirect from http:// // `notice` and the event log's `show`, and that Referrer-Policy is set to
// to https:// or a browser fetches the 404 page's stylesheet from // strict-origin-when-cross-origin. X-Request-Id ties the event to the
// under /h/. The scheme and host are all gorilla/csrf compares a // local access log line, which holds the rest of the detail.
// Referer by, so a CSRF rejection is still readable from the event.
// X-Request-Id ties the event to the local access log line, which
// holds the rest of the detail.
func sentryKeepsHeader(name string) bool { func sentryKeepsHeader(name string) bool {
switch http.CanonicalHeaderKey(name) { switch http.CanonicalHeaderKey(name) {
case "Accept", case "Accept",
-31
View File
@@ -325,37 +325,6 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
) )
} }
// TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost sends a receiver
// request whose Referer is the entrypoint URL, as a sender's is after
// it follows the README's redirect from http:// to https://. The event
// keeps the Referer's scheme and host, and carries the UUID nowhere.
func TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost(t *testing.T) {
t.Parallel()
event := onlyEvent(t, sentryCase{
scrub: true,
panics: true,
request: func(client *sentry.Client) *http.Request {
req := sentryReceiverRequest(client)
req.Header.Set(
"Referer",
"http://webhooker.example/h/"+sentryReceiverUUID,
)
return req
},
}.capture(t))
assert.NotContains(
t, marshalEvent(t, event), sentryReceiverUUID,
)
assert.Equal(
t,
"http://webhooker.example",
event.Request.Headers["Referer"],
)
}
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost // TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
// the debugging signal: the route, its scheme and host, the method and // the debugging signal: the route, its scheme and host, the method and
// the metadata headers still identify what failed. On a static route // the metadata headers still identify what failed. On a static route
-2
View File
@@ -135,8 +135,6 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
s.httpServer = s.newHTTPServer() s.httpServer = s.newHTTPServer()
lc.Append(fx.Hook{ lc.Append(fx.Hook{
//nolint:contextcheck // The listener outlives this hook, whose
// context fx cancels once the app has started.
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
s.startupTime = time.Now() s.startupTime = time.Now()
go s.Run() go s.Run()
+14 -7
View File
@@ -153,15 +153,19 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) {
require.Equal(t, first, second) require.Equal(t, first, second)
} }
// The build target is the only place that stamps the version. If a // The build target is the only place composing linker flags. If a
// future edit drops the -X flag, the binary silently reports "dev" // future edit drops either half, the binary silently reports "dev"
// again, the defect this package exists for. // again (the defect this package exists for) or fails to link on
func TestMakefile_BuildStampsTheVersion(t *testing.T) { // Alpine. If it stops passing GO_TAGS, the image's static binary links
// glibc's getaddrinfo and dlopen again.
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
t.Parallel() t.Parallel()
makefile := read(t, makefilePath) makefile := read(t, makefilePath)
require.Contains(t, makefile, "-X main.version=$(VERSION)") require.Contains(t, makefile, "-X main.version=$(VERSION)")
require.Contains(t, makefile, "$(GO_LDFLAGS)")
require.Contains(t, makefile, "-tags '$(GO_TAGS)'")
require.Contains(t, makefile, "VERSION ?= $(shell script/version)") require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
} }
@@ -220,8 +224,8 @@ func requireMake(t *testing.T) {
} }
} }
// The image compiles through the build target, so it carries the // Every compile in the image goes through the build target, so the
// target's version stamp. // static relink cannot replace the flags that carry the stamp.
func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -230,7 +234,10 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
require.NotContains(t, dockerfile, "go build", require.NotContains(t, dockerfile, "go build",
"a raw go build bypasses the Makefile's -X flag") "a raw go build bypasses the Makefile's -X flag")
require.Contains(t, dockerfile, "ARG VERSION") require.Contains(t, dockerfile, "ARG VERSION")
require.Contains(t, dockerfile, `RUN make build VERSION="$VERSION"`) require.Contains(t, dockerfile,
`make build VERSION="$VERSION"`+
` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+
` GO_LDFLAGS='-extldflags "-static"'`)
} }
func read(t *testing.T, path string) string { func read(t *testing.T, path string) string {
+3 -11
View File
@@ -1,7 +1,7 @@
/* /*
* The two shared styles for the controls a user clicks, and the rule that * The two shared styles for the controls a user clicks. Every page loads
* hides an element marked x-cloak. Every page loads this file after * this file after tailwind.css. It is plain CSS: make css does not build
* tailwind.css. It is plain CSS: make css does not build it. * it.
* *
* A button is btn-primary, btn-secondary or btn-danger, from input.css. * A button is btn-primary, btn-secondary or btn-danger, from input.css.
* A secondary or inline action, such as Copy beside an entrypoint URL or * A secondary or inline action, such as Copy beside an entrypoint URL or
@@ -46,11 +46,3 @@
outline-offset: 2px; outline-offset: 2px;
} }
} }
/*
* Alpine.js removes x-cloak from an element once it has started, so
* something Alpine hides is not seen on the page while it loads.
*/
[x-cloak] {
display: none !important;
}
File diff suppressed because one or more lines are too long
+3 -15
View File
@@ -58,18 +58,6 @@
} }
})(); })();
// Asks before a form with a data-confirm attribute is sent, such as a
// delete form on the webhook page. The browser's own prompt shows the
// attribute's text, and the form is sent only if the user accepts.
document.addEventListener("submit", function (event) {
"use strict";
const message = event.target.getAttribute("data-confirm");
if (message !== null && !window.confirm(message)) {
event.preventDefault();
}
});
// Alpine.js components. // Alpine.js components.
// //
// The pages' Content-Security-Policy forbids eval, so the UI loads // The pages' Content-Security-Policy forbids eval, so the UI loads
@@ -151,7 +139,7 @@ document.addEventListener("alpine:init", function () {
headers: "", headers: "",
timeout: "", timeout: "",
forwardQuery: false, forwardQuery: false,
maxRetries: "", maxAttempts: "",
expiry: "", expiry: "",
rotation: "", rotation: "",
init() { init() {
@@ -165,7 +153,7 @@ document.addEventListener("alpine:init", function () {
this.timeout = refused.timeout; this.timeout = refused.timeout;
this.forwardQuery = this.forwardQuery =
this.$root.hasAttribute("data-forward-query"); this.$root.hasAttribute("data-forward-query");
this.maxRetries = refused.maxRetries; this.maxAttempts = refused.maxAttempts;
this.expiry = refused.expiry; this.expiry = refused.expiry;
this.rotation = refused.rotation; this.rotation = refused.rotation;
}, },
@@ -185,7 +173,7 @@ document.addEventListener("alpine:init", function () {
this.headers = ""; this.headers = "";
this.timeout = ""; this.timeout = "";
this.forwardQuery = false; this.forwardQuery = false;
this.maxRetries = ""; this.maxAttempts = "";
this.expiry = ""; this.expiry = "";
this.rotation = ""; this.rotation = "";
this.$refs.form.reset(); this.$refs.form.reset();
+1 -1
View File
@@ -5,7 +5,7 @@
{{if .Binary}} {{if .Binary}}
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p> <p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{else if .Text}} {{else if .Text}}
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all{{if .Scroll}} max-h-[32rem] overflow-y-auto{{end}}">{{.Text}}</pre> <pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all"{{if .Scroll}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
{{if .Cut}} {{if .Cut}}
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p> <p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{end}} {{end}}
+1
View File
@@ -4,5 +4,6 @@
<title>{{block "title" .}}Webhooker{{end}}</title> <title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css"> <link rel="stylesheet" href="/s/css/tailwind.css">
<link rel="stylesheet" href="/s/css/style.css"> <link rel="stylesheet" href="/s/css/style.css">
<style>[x-cloak] { display: none !important; }</style>
{{block "head" .}}{{end}} {{block "head" .}}{{end}}
{{end}} {{end}}
+5 -3
View File
@@ -13,11 +13,13 @@
<div class="p-4"> <div class="p-4">
<!-- A value too wide to sit beside its name moves to the <!-- A value too wide to sit beside its name moves to the
next line, where a list breaks only at the spaces next line, where a list breaks only at the spaces
between its entries. wrap-anywhere breaks inside a between its entries. overflow-wrap: anywhere breaks
value only when it alone is wider than the line. --> inside a value only when it alone is wider than the
line; an inline style, because the committed
tailwind.css has no class for it. -->
<div class="flex flex-wrap justify-between items-start gap-4"> <div class="flex flex-wrap justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code> <code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900 wrap-anywhere">{{.Value}}</code> <code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
</div> </div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p> <p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div> </div>
+18 -17
View File
@@ -5,10 +5,12 @@
{{define "content"}} {{define "content"}}
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the <!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on event log, the navbar and the footer, so an entrypoint URL fits on
one line. wrap-anywhere goes only on names and descriptions: a one line. An inline style, because the committed tailwind.css has
row too wide for a phone must still run past the edge, where the no class this wide. wrap-anywhere goes only on names and
browser test sees it, rather than break its controls mid-word. --> descriptions: a row too wide for a phone must still run past the
<div class="max-w-[108rem] mx-auto px-6 py-8"> edge, where the browser test sees it, rather than break its
controls mid-word. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="btn-small">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
@@ -21,12 +23,11 @@
<div class="flex gap-2"> <div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a> <a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a> <a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<!-- Before a delete form is sent, static/js/app.js <!-- The delete prompts are the browser's own, so they
asks in the browser's own prompt, with the form's work without the page's scripts. The template
data-confirm text; &#10; is a line break. The escapes each name for the script, so a quote or a
template escapes each name for the attribute, so a backslash in it shows as typed. -->
quote in it shows as typed. --> <form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook &quot;{{.Webhook.Name}}&quot;?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" data-confirm="Delete webhook &quot;{{.Webhook.Name}}&quot;?&#10;&#10;This deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button> <button type="submit" class="btn-danger">Delete</button>
</form> </form>
@@ -89,7 +90,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" data-confirm="Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?&#10;&#10;Senders using its URL get an error from now on, and the URL cannot be restored." class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button> <button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form> </form>
@@ -135,7 +136,7 @@
data-headers="{{.TargetForm.Headers}}" data-headers="{{.TargetForm.Headers}}"
data-timeout="{{.TargetForm.Timeout}}" data-timeout="{{.TargetForm.Timeout}}"
{{if .TargetForm.ForwardQuery}}data-forward-query{{end}} {{if .TargetForm.ForwardQuery}}data-forward-query{{end}}
data-max-retries="{{.TargetForm.MaxRetries}}" data-max-attempts="{{.TargetForm.MaxAttempts}}"
data-expiry="{{.TargetForm.Expiry}}" data-expiry="{{.TargetForm.Expiry}}"
data-rotation="{{.TargetForm.Rotation}}"> data-rotation="{{.TargetForm.Rotation}}">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
@@ -193,9 +194,9 @@
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -209,9 +210,9 @@
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -277,7 +278,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" data-confirm="Delete target &quot;{{.Name}}&quot;?&#10;&#10;Nothing more is delivered to it. Its past deliveries stay in the event log." class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target &quot;{{.Name}}&quot;?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button> <button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form> </form>
+3 -3
View File
@@ -89,9 +89,9 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_retries" class="label">Delivery attempts</label> <label for="max_attempts" class="label">Delivery attempts</label>
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input"> <input type="number" id="max_attempts" name="max_attempts" value="{{.TargetForm.MaxAttempts}}" min="1" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p>
</div> </div>
{{end}} {{end}}