Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7287e163e7 |
+4
-2
@@ -3,8 +3,10 @@
|
|||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Third-party browser assets are fetched and hash-verified inside the build by
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
||||||
|
# from supplying the bytes that get shipped. The script and its
|
||||||
|
# static/vendor.sha256 manifest stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|||||||
+4
-3
@@ -46,6 +46,7 @@ temp/
|
|||||||
# CI cache barrier, written into the build context by the check workflow
|
# CI cache barrier, written into the build context by the check workflow
|
||||||
.ci-fingerprint
|
.ci-fingerprint
|
||||||
|
|
||||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
# Third-party browser assets, fetched and hash-verified by
|
||||||
# what is committed.
|
# script/fetch-assets against static/vendor.sha256. Not committed:
|
||||||
/static/js/alpine.min.js
|
# REPO_POLICIES.md forbids minified bundles in version control.
|
||||||
|
/static/js/alpine.min.js
|
||||||
@@ -7,9 +7,6 @@ version: "2"
|
|||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
modules-download-mode: readonly
|
modules-download-mode: readonly
|
||||||
# Lint the browser test too (make test-browser builds it with this tag).
|
|
||||||
build-tags:
|
|
||||||
- browser
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
|||||||
Binary file not shown.
+11
-4
@@ -51,8 +51,15 @@ RUN go mod download
|
|||||||
# the lint stage above.
|
# the lint stage above.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
# Fetch the third-party browser assets the UI serves. They are not committed
|
||||||
# from its tarball in 3p/.
|
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
||||||
|
# .dockerignore keeps any host copy out of the build context, so this step is
|
||||||
|
# the only way they enter the image. Each download is checked against a
|
||||||
|
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
||||||
|
# hashes against the bytes go:embed actually put in the binary.
|
||||||
|
RUN script/fetch-assets
|
||||||
|
|
||||||
|
# Run tests and build
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||||
@@ -60,8 +67,8 @@ RUN make test
|
|||||||
# host and passes it in. The default is what a bare `docker build .`
|
# host and passes it in. The default is what a bare `docker build .`
|
||||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||||
#
|
#
|
||||||
# Declared here, below the test step, so a changed version does not
|
# Declared here, below the test and asset steps, so a changed version
|
||||||
# invalidate its cached layer.
|
# does not invalidate their cached layers.
|
||||||
ARG VERSION=unknown
|
ARG VERSION=unknown
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Browser test image, built by script/test-browser (make test-browser). It
|
|
||||||
# runs the test in internal/server that loads the pages in a headless
|
|
||||||
# browser under the real Content-Security-Policy. That test is built only
|
|
||||||
# with the browser build tag, so make test leaves it out. Here the browser
|
|
||||||
# comes from a digest-pinned image, and if it is missing the test fails.
|
|
||||||
|
|
||||||
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The test binary embeds the templates and static files, so the browser
|
|
||||||
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
|
||||||
# script/test.
|
|
||||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
|
||||||
|
|
||||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
|
||||||
# browser is on PATH as headless-shell, where the test's browser library
|
|
||||||
# looks for it.
|
|
||||||
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
|
||||||
|
|
||||||
COPY --from=build /browser.test /browser.test
|
|
||||||
|
|
||||||
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -28,14 +28,11 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
@script/assets
|
@script/fetch-assets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-browser:
|
|
||||||
@script/test-browser
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
@@ -48,13 +45,13 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build: assets
|
build:
|
||||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
|
|
||||||
dev: assets
|
dev:
|
||||||
go run ./cmd/webhooker
|
go run ./cmd/webhooker
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for linting, for the browser test, for the test stage of the
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
CI gate, and for containerized deployment)
|
containerized deployment)
|
||||||
|
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||||
|
browser assets, which are not committed (`make bootstrap` installs
|
||||||
|
it if missing)
|
||||||
|
|
||||||
golangci-lint is not a prerequisite and must not be installed on the
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
@@ -33,7 +36,9 @@ digest-pinned linter image via `Dockerfile.lint`.
|
|||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
cd webhooker
|
cd webhooker
|
||||||
|
|
||||||
# Install the Go toolchain if missing, and the Go dependencies
|
# Install Go dependencies and the third-party browser assets.
|
||||||
|
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||||
|
# and the checks below need the fetched assets.
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check)
|
# Run all checks (test, lint, format check)
|
||||||
@@ -53,12 +58,11 @@ make docker
|
|||||||
```bash
|
```bash
|
||||||
make bootstrap # Install all dependencies (idempotent)
|
make bootstrap # Install all dependencies (idempotent)
|
||||||
make setup # Bootstrap + install git pre-commit hook
|
make setup # Bootstrap + install git pre-commit hook
|
||||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
make assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
@@ -143,7 +147,7 @@ TTY detection, and security headers are always applied.
|
|||||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
@@ -158,21 +162,6 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
|
|||||||
WireServer, which serves an Azure VM its credentials. Because it is a
|
WireServer, which serves an Azure VM its credentials. Because it is a
|
||||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||||
|
|
||||||
That is all the default blocklist covers: the IPv4 private and reserved
|
|
||||||
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
|
||||||
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
|
||||||
addresses. A public address belongs on the default blocklist only if it
|
|
||||||
hands credentials, user data or bootstrap material to whatever can reach
|
|
||||||
it, without the caller presenting anything. A provider's other public
|
|
||||||
addresses are not refused. IBM Cloud, for example, serves its package
|
|
||||||
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
|
||||||
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
|
||||||
range hands out credentials that way: the token service among those
|
|
||||||
endpoints issues a token only in exchange for something the caller
|
|
||||||
presents, such as an API key. Reaching these services can be a
|
|
||||||
legitimate delivery, and every cloud has some, so a partial list would
|
|
||||||
promise coverage it does not give.
|
|
||||||
|
|
||||||
That default is also inconvenient for the thing webhooker is mostly
|
That default is also inconvenient for the thing webhooker is mostly
|
||||||
for: taking a public webhook and forwarding it to something on your own
|
for: taking a public webhook and forwarding it to something on your own
|
||||||
network. A container on the same Docker network, a box on `10.x`, a
|
network. A container on the same Docker network, a box on `10.x`, a
|
||||||
@@ -390,37 +379,41 @@ unlocked.
|
|||||||
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
||||||
address such as `192.168.1.7` is accepted and treated as a single
|
address such as `192.168.1.7` is accepted and treated as a single
|
||||||
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
||||||
`X-Forwarded-For` header the rate limiters believe, so it should cover
|
`X-Forwarded-For` header the rate limiters believe, so it should name
|
||||||
the addresses of your reverse proxies.
|
the addresses of your reverse proxies and nothing else.
|
||||||
|
|
||||||
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
||||||
inside one of these blocks; for every other peer the client identity is
|
inside one of these blocks; for every other peer the client identity is
|
||||||
the connection's own address and the header is ignored. Unset (or
|
the connection's own address and the header is ignored. The default is
|
||||||
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
|
the empty list, which trusts nobody — anything else would let any
|
||||||
`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default
|
client pick its own rate limit bucket, minting a fresh one per request
|
||||||
entirely. A set but unparseable value aborts startup.
|
or draining someone else's. Set it to the address of your reverse
|
||||||
|
proxy, and to nothing wider. A set but unparseable value aborts
|
||||||
|
startup.
|
||||||
|
|
||||||
If any client can reach webhooker, or the proxy in front of it, from an
|
That default is safe against forged headers, but leaving it unset in
|
||||||
RFC 1918 source address (directly, or through anything that can
|
production has a cost you must know about. Production runs behind a
|
||||||
rewrite source addresses, such as NAT or a published container port),
|
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
|
||||||
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
|
request keys on the proxy's own address and all clients share a single
|
||||||
limit, the webhook receiver's included, can be bypassed by those
|
bucket per limit. The receiver limits become service-wide ceilings,
|
||||||
clients. The address to set is the `remoteIP` field of the
|
and the login endpoint's failure counting collapses onto one key, so a
|
||||||
`http request` log line for a request that came through the proxy.
|
stranger's wrong passwords throttle every other client's wrong
|
||||||
|
passwords.
|
||||||
Behind a proxy the list does not cover, every request keys on the
|
|
||||||
proxy's own address and all clients share a single bucket per limit.
|
|
||||||
The receiver limits become service-wide ceilings, and the login
|
|
||||||
endpoint's failure counting collapses onto one key, so a stranger's
|
|
||||||
wrong passwords throttle every other client's wrong passwords. Set
|
|
||||||
`TRUSTED_PROXIES` to that proxy's address to restore per-client
|
|
||||||
buckets.
|
|
||||||
|
|
||||||
What it cannot do is lock the operator out. The login endpoint
|
What it cannot do is lock the operator out. The login endpoint
|
||||||
verifies credentials **before** it consults any limit and charges only
|
verifies credentials **before** it consults any limit and charges only
|
||||||
failures, so a correct password is never throttled no matter how full
|
failures, so a correct password is never throttled no matter how full
|
||||||
the bucket is. See [Rate Limiting](#rate-limiting).
|
the bucket is. See [Rate Limiting](#rate-limiting).
|
||||||
|
|
||||||
|
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
|
||||||
|
address, which restores per-client buckets. webhooker logs a warning
|
||||||
|
at startup whenever `TRUSTED_PROXIES` is empty, in every environment,
|
||||||
|
because behind a proxy every client shares one bucket in `dev` and
|
||||||
|
`prod` alike. The warning is informational when nothing proxies to the
|
||||||
|
process: with no proxy in front, the peer address is the client's own
|
||||||
|
and the buckets are already per-client. See
|
||||||
|
[Rate Limiting](#rate-limiting) for what each limit shares.
|
||||||
|
|
||||||
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
||||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||||
headers verbatim, so a single-valued header is client-controlled even
|
headers verbatim, so a single-valued header is client-controlled even
|
||||||
@@ -436,10 +429,20 @@ instead, since past such an entry the chain is not the shape assumed
|
|||||||
here. The peer address is likewise used when the header is absent or
|
here. The peer address is likewise used when the header is absent or
|
||||||
every hop in it is a trusted proxy.
|
every hop in it is a trusted proxy.
|
||||||
|
|
||||||
Your proxy must therefore **append** the peer address to
|
Two operator requirements follow:
|
||||||
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
|
|
||||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
- Your proxy must **append** the peer address to `X-Forwarded-For`
|
||||||
bare address with no port.
|
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
||||||
|
Caddy and AWS ALB by default), and must append a bare address with
|
||||||
|
no port.
|
||||||
|
- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES`
|
||||||
|
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
|
||||||
|
it can name a different address on every request to get a fresh
|
||||||
|
bucket each time, or name another client's address to drain that
|
||||||
|
client's bucket. Never list a block that also covers clients — a
|
||||||
|
broad `10.0.0.0/8` on a network where clients live in the same range
|
||||||
|
makes all three limits, including the unauthenticated webhook
|
||||||
|
receiver, silently bypassable by every client in the block.
|
||||||
|
|
||||||
#### Sessions
|
#### Sessions
|
||||||
|
|
||||||
@@ -738,15 +741,10 @@ repository's `Dockerfile` and runs it. The app needs:
|
|||||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||||
- **Environment variables:**
|
- **Environment variables:**
|
||||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||||
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
|
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
||||||
your reverse proxy's address alone if that address is outside
|
network. The `remoteIP` field of the `http request` log line for a
|
||||||
those ranges, or if any client can reach webhooker, or the proxy,
|
request that came through the proxy shows it; the health check's
|
||||||
from an RFC 1918 source address (directly, or through anything
|
own lines show `::1`. See [Trusted proxies](#trusted-proxies).
|
||||||
that can rewrite source addresses, such as NAT or a published
|
|
||||||
container port). The `remoteIP` field of the `http request` log
|
|
||||||
line for a request that came through the proxy shows that
|
|
||||||
address; the health check's own lines show `::1`. See
|
|
||||||
[Trusted proxies](#trusted-proxies).
|
|
||||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
||||||
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
||||||
`/var/lib/webhooker`.
|
`/var/lib/webhooker`.
|
||||||
@@ -809,16 +807,12 @@ reports.
|
|||||||
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
||||||
sets it; without it every request is read as plaintext and cookies
|
sets it; without it every request is read as plaintext and cookies
|
||||||
ship without `Secure`. See [Configuration](#configuration).
|
ship without `Secure`. See [Configuration](#configuration).
|
||||||
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
|
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||||
proxy it does not cover, every rate limiter keys on the proxy, so
|
limiter keys on the connecting peer, which behind a proxy is the
|
||||||
all clients share one bucket per limit. Unset, the list is the RFC
|
proxy on every request: all clients collapse into one global bucket
|
||||||
1918 ranges, which do not cover a proxy that reaches the binary
|
per limit and the receiver's per-IP limits become service-wide
|
||||||
itself over loopback (the binary bound to `127.0.0.1`). With the
|
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
||||||
image, the address to check is the `remoteIP` field of the
|
and nothing else.
|
||||||
`http request` log line for a request that came through the proxy.
|
|
||||||
If any client can reach webhooker, or the proxy, from an RFC 1918
|
|
||||||
source address, set the list to the proxy's address alone. See
|
|
||||||
[Trusted proxies](#trusted-proxies).
|
|
||||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||||
port. webhooker's Origin/Referer check compares against the host it
|
port. webhooker's Origin/Referer check compares against the host it
|
||||||
was given, so on any port other than 443 `$host` makes every form
|
was given, so on any port other than 443 `$host` makes every form
|
||||||
@@ -1076,7 +1070,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`
|
`DeliveryResult`, `Totals`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1225,17 +1219,16 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Eleven of the Makefile's eighteen targets are thin
|
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
`version` are inline commands with no script behind them, though `build`,
|
`version` are inline commands with no script behind them, though
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`build` and `version` both take their value from `script/version`.
|
||||||
take their value from `script/version`.
|
|
||||||
|
|
||||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
`make check` needs the third-party browser assets in `static/`, which
|
||||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
are not committed, so run `make bootstrap` (or just `make assets`) once
|
||||||
[Third-party browser assets](#third-party-browser-assets)), so
|
after cloning. Without them the tests fail with a message naming that
|
||||||
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
remedy. `make check` does not fetch them itself because it must not
|
||||||
without a separate step.
|
change any files in the repo.
|
||||||
|
|
||||||
We provide:
|
We provide:
|
||||||
|
|
||||||
@@ -1243,11 +1236,9 @@ We provide:
|
|||||||
- `script/setup` — make a fresh clone ready for development
|
- `script/setup` — make a fresh clone ready for development
|
||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("webhooker")
|
- `script/projectname` — output the project name ("webhooker")
|
||||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/test-browser` — run the browser test in Docker (see
|
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
|
||||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
@@ -1268,46 +1259,24 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
||||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
||||||
the standard `alpinejs` build needs to run the expressions written in the
|
both committed build artifacts and unpinned external references.
|
||||||
markup. The CSP build runs no expressions, so every Alpine directive in
|
|
||||||
`templates/` only names a property or method of a component registered in
|
|
||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page and the event log
|
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||||
under the real policy and checks that: both add forms stay hidden until Add is
|
download against a hardcoded sha256, and installs it under `static/`. The
|
||||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
||||||
what it submits; an event expands and collapses, and so do a delivery's
|
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
||||||
attempts inside it; and at phone width the menu button opens and closes the
|
against that manifest — so the pin is enforced on what actually ships, not
|
||||||
mobile menu. It also fails if the browser reports a console warning or error,
|
merely written down. Any mismatch fails the build.
|
||||||
an uncaught exception, or anything the policy refused. It is not part of
|
|
||||||
`make test`, `make check` or the image build (its file is built only with the
|
|
||||||
`browser` build tag). Run it with `make test-browser` after changing
|
|
||||||
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
|
|
||||||
test in a digest-pinned headless browser image, so the host needs no browser.
|
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's
|
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
||||||
build output, so
|
artifact out of both the repo and the build context.
|
||||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
|
||||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
|
||||||
`vendor/` directory as its module vendor directory.
|
|
||||||
|
|
||||||
`script/assets` (`make assets`) extracts the browser build,
|
To move to a new version: update the version, URL, and tarball sha256 in
|
||||||
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
||||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
run `make assets && make check`.
|
||||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
|
||||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
|
||||||
`.dockerignore` keeps any host copy out of the build context.
|
|
||||||
|
|
||||||
To move to a new version: download
|
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
|
|
||||||
against the `dist.integrity` hash listed at
|
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
|
||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
|
||||||
`script/assets`, and run `make check`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -1394,11 +1363,10 @@ It uses:
|
|||||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||||
sliding-window rate limiting of the password-change and webhook
|
sliding-window rate limiting of the password-change and webhook
|
||||||
receiver endpoints. The bucket is per client IP only when
|
receiver endpoints. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
RFC 1918 private ranges); otherwise every client behind that proxy
|
behind that proxy shares one bucket per limit. The login endpoint
|
||||||
shares one bucket per limit. The login endpoint counts failed
|
counts failed attempts itself instead, so that a correct password is
|
||||||
attempts itself instead, so that a correct password is never
|
never throttled (see [Rate Limiting](#rate-limiting))
|
||||||
throttled (see [Rate Limiting](#rate-limiting))
|
|
||||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
`/metrics` behind basic auth
|
`/metrics` behind basic auth
|
||||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
@@ -1416,7 +1384,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has nine entities organized into two tiers: the
|
webhooker's data model has ten entities organized into two tiers: the
|
||||||
**application tier** (user and webhook configuration) and the **event
|
**application tier** (user and webhook configuration) and the **event
|
||||||
tier** (event ingestion, delivery, and logging).
|
tier** (event ingestion, delivery, and logging).
|
||||||
|
|
||||||
@@ -1445,6 +1413,10 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ │
|
||||||
|
│ │ Totals │ (one row of running counts) │
|
||||||
|
│ └──────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1677,7 +1649,6 @@ data for auditing, for replay, and for resubmission.
|
|||||||
| `headers` | JSON | Complete request headers |
|
| `headers` | JSON | Complete request headers |
|
||||||
| `body` | text | Raw request body |
|
| `body` | text | Raw request body |
|
||||||
| `content_type` | string | Content-Type header value |
|
| `content_type` | string | Content-Type header value |
|
||||||
| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit |
|
|
||||||
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
||||||
@@ -1698,6 +1669,7 @@ status across potentially multiple attempts.
|
|||||||
| `event_id` | UUID | Foreign key → Event |
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
|
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1765,6 +1737,29 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
|
#### Totals
|
||||||
|
|
||||||
|
The one row of running counts in each event database, read by the
|
||||||
|
statistics pane at the top of the webhook page.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------------- | ------- | ----------- |
|
||||||
|
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||||
|
| `deliveries` | integer | Deliveries ever created, replays included |
|
||||||
|
| `failures` | integer | Deliveries that ever became `failed` |
|
||||||
|
| `events_removed` | integer | Events retention has deleted |
|
||||||
|
| `deliveries_removed` | integer | Deliveries retention has deleted |
|
||||||
|
| `failures_removed` | integer | Failed deliveries retention has deleted |
|
||||||
|
|
||||||
|
Each count changes in the transaction that writes or deletes the rows it
|
||||||
|
counts. The pane shows each of the first three as a lifetime figure, and
|
||||||
|
less what retention removed as the figure within retention, so neither
|
||||||
|
needs the rows themselves. Its last-10-minutes and last-24-hours figures
|
||||||
|
are counted from the `events` and `deliveries` indexes over just that
|
||||||
|
window. Its failure percentage for a window is the deliveries that became
|
||||||
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
|
a dash when none did.
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
@@ -1772,10 +1767,10 @@ tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
|||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
| `deliveries` | `status`, `deleted_at`, `finished_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count deliveries by status and when they finished |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age, and the webhook page's statistics, which count recent events and find the newest |
|
||||||
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||||
@@ -1789,9 +1784,10 @@ and SQLite narrows by a `<` only on the last column it uses.
|
|||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting` includes these fields from `BaseModel`.
|
Every entity except `Setting` and `Totals` includes these fields from
|
||||||
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
`BaseModel`. `Setting` is a bare key-value row with no `id`, no
|
||||||
soft delete:
|
timestamps and no soft delete, and `Totals` is a single row of counts
|
||||||
|
with only a numeric `id`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1833,6 +1829,7 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
|
- **Totals** — running counts of the above, kept through retention
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created (and lazily on first access for webhooks that predate this
|
created (and lazily on first access for webhooks that predate this
|
||||||
@@ -2569,44 +2566,47 @@ the tree is checked out: four checkouts have reported 3,959, 3,961,
|
|||||||
client-supplied field was cut, and that the shipped chain's stack
|
client-supplied field was cut, and that the shipped chain's stack
|
||||||
arrived uncut — never the numbers.
|
arrived uncut — never the numbers.
|
||||||
|
|
||||||
Every limiter here — receiver, login, password change, delivery replay
|
Every limiter here — receiver, login, and password change — identifies
|
||||||
and event resubmit — identifies the client the same way, through one
|
the client the same way, through one shared key function: the
|
||||||
shared key function: the connection's own address, unless the peer is
|
connection's own address, unless the peer is listed in
|
||||||
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||||
used instead. That address becomes a bucket by family: IPv4 keys on
|
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||||
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||||
request, evading these limits at the network layer without spoofing
|
request, evading these limits at the network layer without spoofing
|
||||||
anything; the cost is that distinct clients inside one `/64` share a
|
anything; the cost is that distinct clients inside one `/64` share a
|
||||||
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
||||||
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||||
does not cover the reverse proxy, a client behind it shares one bucket
|
variable set, a client behind a reverse proxy shares one bucket with
|
||||||
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||||
the proxy's address to get per-client limits back. What the shared bucket
|
proxy's address to get per-client limits back. What the shared bucket
|
||||||
costs is not the same for every limiter, and the two cases pull in
|
costs is not the same for every limiter, and the two cases pull in
|
||||||
opposite directions:
|
opposite directions:
|
||||||
|
|
||||||
- For the **receiver** limits it costs throughput, which is the safe
|
- For the **receiver** limits it costs throughput, which is the safe
|
||||||
direction to be wrong in: sharing can only make a limit bind sooner,
|
direction to be wrong in: sharing can only make a limit bind sooner,
|
||||||
never let a sender past it. It matters more for the aggregate limit
|
never let a sender past it. It matters more for the aggregate limit
|
||||||
than for the per-entrypoint one: with every request keyed on the
|
than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
|
||||||
proxy, the aggregate limit becomes a service-wide ceiling of 1200
|
the reverse proxy a production deployment is required to run behind,
|
||||||
requests per minute across all senders and all entrypoints, where the
|
every request keys on the proxy, so the aggregate limit becomes a
|
||||||
per-entrypoint limit's capacity still grows with the number of
|
service-wide ceiling of 1200 requests per minute across all senders
|
||||||
entrypoints.
|
and all entrypoints, where the per-entrypoint limit's capacity still
|
||||||
|
grows with the number of entrypoints. Any deployment with more than a
|
||||||
|
handful of busy entrypoints must set `TRUSTED_PROXIES`.
|
||||||
- For the **login and password-change** limits it costs precision, not
|
- For the **login and password-change** limits it costs precision, not
|
||||||
availability. Login failures from every client land in one counter,
|
availability. Login failures from every client land in one counter,
|
||||||
so a stranger's wrong passwords make the operator's own wrong
|
so a stranger's wrong passwords make the operator's own wrong
|
||||||
passwords answer `429` sooner; the operator's _correct_ password is
|
passwords answer `429` sooner; the operator's _correct_ password is
|
||||||
never affected, because it is never counted.
|
never affected, because it is never counted. Production deployments
|
||||||
|
should still set `TRUSTED_PROXIES`; webhooker warns at startup
|
||||||
|
whenever it is empty, in any environment.
|
||||||
|
|
||||||
#### The login endpoint
|
#### The login endpoint
|
||||||
|
|
||||||
The login `POST` is the one endpoint with no pre-emptive limiter in
|
The login `POST` is the one endpoint with no pre-emptive limiter in
|
||||||
front of it, and that is deliberate. A limiter that spends budget on
|
front of it, and that is deliberate. A limiter that spends budget on
|
||||||
arrival is a lockout wherever clients share one bucket, as they do
|
arrival is a lockout in this deployment shape: sharing one bucket, a
|
||||||
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
|
||||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||||
from anywhere — keeps it permanently full, and the operator has no
|
from anywhere — keeps it permanently full, and the operator has no
|
||||||
second administrative path. So the handler inverts the order:
|
second administrative path. So the handler inverts the order:
|
||||||
@@ -2703,10 +2703,8 @@ re-fills both verification slots on its first two requests. The
|
|||||||
remedies are to block the source at the reverse proxy, or to
|
remedies are to block the source at the reverse proxy, or to
|
||||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||||
applied without reintroducing the lockout, because the proxy sees the
|
applied without reintroducing the lockout, because the proxy sees the
|
||||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
||||||
The flood's source is in the proxy's access log: webhooker's own logs
|
saturation, but it makes the source visible in the failure logs.
|
||||||
record the proxy's address, not the client's (see
|
|
||||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
|
||||||
|
|
||||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
enforced in the webhook handler) can layer on top of this env-level
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
@@ -2787,8 +2785,6 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
|
||||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
|
||||||
├── cmd/webhooker/
|
├── cmd/webhooker/
|
||||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||||
├── internal/
|
├── internal/
|
||||||
@@ -2881,14 +2877,14 @@ webhooker/
|
|||||||
│ ├── 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 # Older hand-written stylesheet, no longer loaded
|
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||||
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
|
||||||
|
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -3065,9 +3061,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
It runs behind session auth, so only a client already holding a
|
It runs behind session auth, so only a client already holding a
|
||||||
valid session reaches it, and an operator throttled out of changing
|
valid session reaches it, and an operator throttled out of changing
|
||||||
a password can still log in. The bucket is per client IP only when
|
a password can still log in. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
shares one bucket, which costs precision rather than availability
|
shares one bucket, which costs precision rather than availability
|
||||||
(see [Rate Limiting](#rate-limiting))
|
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||||
|
whenever `TRUSTED_PROXIES` is empty
|
||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
@@ -3194,14 +3191,14 @@ version is fixed independently of the compiler's:
|
|||||||
`make fmt-check`, then `golangci-lint config verify` and
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
`golangci-lint run`, both with `--network=none`.
|
`golangci-lint run`, both with `--network=none`.
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
stage passing (it copies a file from it), runs `make test` and
|
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
to download and verify the third-party browser assets, then runs
|
||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
`make test` and `make build`, and finally rebuilds the binary with
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
go through `make build`, the relink adding its `-extldflags` via
|
||||||
stamps the version. The version arrives as the `VERSION` build arg,
|
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||||
since the context has no `.git` (see
|
The version arrives as the `VERSION` build arg, since the context
|
||||||
[Version stamping](#version-stamping)).
|
has no `.git` (see [Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
directory for all SQLite databases, exposes port 8080, and includes
|
directory for all SQLite databases, exposes port 8080, and includes
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ point of the branch.
|
|||||||
- 2026-03-05 security headers middleware, session regeneration on
|
- 2026-03-05 security headers middleware, session regeneration on
|
||||||
login, request body size limits (#41)
|
login, request body size limits (#41)
|
||||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
- 2026-03-04 tests for delivery, middleware, and session packages
|
||||||
(#32); removed the build-architecture global (#31)
|
(#32); removed globals.Buildarch (#31)
|
||||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
||||||
delivery engine with bounded worker pool and circuit breaker,
|
delivery engine with bounded worker pool and circuit breaker,
|
||||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
|
||||||
github.com/chromedp/chromedp v0.16.0
|
|
||||||
github.com/dustin/go-humanize v1.0.1
|
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
@@ -31,12 +28,8 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -57,7 +50,7 @@ require (
|
|||||||
go.uber.org/zap v1.23.0 // indirect
|
go.uber.org/zap v1.23.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
|
||||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
|
||||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
|
||||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
|
||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -29,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -69,16 +55,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -129,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
|||||||
+60
-22
@@ -75,11 +75,6 @@ const (
|
|||||||
// internet-exposed endpoint.
|
// internet-exposed endpoint.
|
||||||
defaultReceiverRateLimit = 120
|
defaultReceiverRateLimit = 120
|
||||||
|
|
||||||
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
|
|
||||||
// RFC 1918 private ranges, which a reverse proxy reaching the
|
|
||||||
// process over a Docker network or a private LAN connects from.
|
|
||||||
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number. The lower
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
// bound (at least 1) is enforced by envPositiveInt.
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
@@ -177,14 +172,13 @@ type Config struct {
|
|||||||
|
|
||||||
// TrustedProxies is the set of networks whose members are
|
// TrustedProxies is the set of networks whose members are
|
||||||
// allowed to speak for the client with X-Forwarded-For, the
|
// allowed to speak for the client with X-Forwarded-For, the
|
||||||
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
// only forwarded header read. It is empty unless
|
||||||
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
|
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||||
// value replaces them. If any client can reach the process, or
|
// trusted: forwarded headers are then ignored entirely and
|
||||||
// the proxy in front of it, from an RFC 1918 source address
|
// clients are identified by the connection's own address.
|
||||||
// (directly, or through anything that can rewrite source
|
// Members can choose their own rate-limit key, so this must
|
||||||
// addresses, such as NAT or a published container port), it
|
// name proxy hosts only, never a block that also covers
|
||||||
// must be set to the proxy's address alone, or every rate limit
|
// clients.
|
||||||
// can be bypassed by those clients.
|
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||||
@@ -466,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
|
|||||||
|
|
||||||
// envPrefixList returns the value of the named environment variable
|
// envPrefixList returns the value of the named environment variable
|
||||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||||
// allowed). An unset, empty, or blank value is read as defaultValue
|
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||||
// instead. A set value containing an unparseable entry is a hard
|
// set value containing an unparseable entry is a hard error naming
|
||||||
// error naming the key and the bad entry, so startup fails loudly
|
// the key and the bad entry, so startup fails loudly rather than
|
||||||
// rather than silently running with a list the operator did not
|
// silently running with a list the operator did not intend.
|
||||||
// intend.
|
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||||
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
|
||||||
v := strings.TrimSpace(os.Getenv(key))
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
if v == "" {
|
if v == "" {
|
||||||
v = defaultValue
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefixes []netip.Prefix
|
var prefixes []netip.Prefix
|
||||||
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -767,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnSharedRateLimitBucket logs a startup warning whenever
|
||||||
|
// TRUSTED_PROXIES is empty, in any environment.
|
||||||
|
//
|
||||||
|
// With no trusted proxies every rate limiter keys on the connecting
|
||||||
|
// peer's address. Whether that is harmless or dangerous depends on
|
||||||
|
// what is in front of the process, which this code cannot observe:
|
||||||
|
// with nothing in front, the peer is the client and the limits are
|
||||||
|
// per-client as intended; behind a reverse proxy the peer is the proxy
|
||||||
|
// for every request, so all clients share one bucket per limiter.
|
||||||
|
//
|
||||||
|
// The login endpoint no longer spends budget on arrival — it verifies
|
||||||
|
// credentials first and charges only failures — so a shared bucket
|
||||||
|
// cannot deny the operator a correct password. What it does collapse
|
||||||
|
// is the failure counting: one client's wrong passwords throttle
|
||||||
|
// everyone else's wrong passwords, and the receiver's limits become
|
||||||
|
// service-wide ceilings.
|
||||||
|
//
|
||||||
|
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
||||||
|
// behind a proxy every client shares one bucket in dev and prod alike.
|
||||||
|
//
|
||||||
|
// The default of trusting nobody is deliberate — trusting forwarded
|
||||||
|
// headers from arbitrary peers lets any client choose its own bucket —
|
||||||
|
// so this warns rather than failing startup or changing the key.
|
||||||
|
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
||||||
|
if len(c.TrustedProxies) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn(
|
||||||
|
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
||||||
|
"connecting peer's address. With nothing proxying to "+
|
||||||
|
"this process that is the client itself and the limits "+
|
||||||
|
"are per-client as intended. Behind a reverse proxy the "+
|
||||||
|
"peer is the proxy on every request, so all clients "+
|
||||||
|
"share one bucket per limit: the receiver limits become "+
|
||||||
|
"service-wide ceilings, and one client's failed logins "+
|
||||||
|
"throttle every other client's failed logins — a "+
|
||||||
|
"correct password still gets in. If anything proxies to "+
|
||||||
|
"this process, set TRUSTED_PROXIES to its address.",
|
||||||
|
"environment", c.Environment,
|
||||||
|
"trustedProxies", len(c.TrustedProxies),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a Config by reading environment variables.
|
// New creates a Config by reading environment variables.
|
||||||
//
|
//
|
||||||
//nolint:revive // lc parameter is required by fx even if unused.
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
@@ -812,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
s.warnSharedRateLimitBucket(log)
|
||||||
s.warnEgressAllowlist(log)
|
s.warnEgressAllowlist(log)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
+101
-14
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTrustedProxies(t *testing.T) {
|
func TestTrustedProxies(t *testing.T) {
|
||||||
// Unset, the RFC 1918 private ranges are trusted, so a reverse
|
|
||||||
// proxy on a Docker network or a private LAN is covered without
|
|
||||||
// configuration.
|
|
||||||
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
// The default must be "trust nobody": an empty list
|
||||||
|
// means forwarded headers are ignored, never that
|
||||||
|
// every peer may speak for the client.
|
||||||
name: caseUnsetUsesDefault,
|
name: caseUnsetUsesDefault,
|
||||||
set: false,
|
set: false,
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank value uses default",
|
name: "blank value trusts nothing",
|
||||||
set: true,
|
set: true,
|
||||||
value: " ",
|
value: " ",
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set value replaces the default entirely",
|
|
||||||
set: true,
|
|
||||||
value: "203.0.113.7",
|
|
||||||
expected: []string{"203.0.113.7/32"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: caseValidValueParsed,
|
name: caseValidValueParsed,
|
||||||
@@ -853,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSharedRateLimitBucketWarning covers the startup warning that
|
||||||
|
// tells an operator a deployment behind a reverse proxy shares one
|
||||||
|
// rate-limit bucket between every client, which turns the receiver
|
||||||
|
// limits into service-wide ceilings and collapses login failure
|
||||||
|
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||||
|
// environment, because behind a proxy every client shares one bucket
|
||||||
|
// in dev and prod alike. It stays quiet once proxies are named.
|
||||||
|
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
environment string
|
||||||
|
trustedProxies string
|
||||||
|
expectWarning bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prod without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prod with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
||||||
|
|
||||||
|
if tt.trustedProxies == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("TRUSTED_PROXIES"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
log := slog.New(slog.NewJSONHandler(
|
||||||
|
&buf, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
config.WarnSharedRateLimitBucketForTest(log),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tt.expectWarning {
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, logged, `"level":"WARN"`)
|
||||||
|
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
||||||
|
assert.Contains(t, logged, "share one bucket")
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "throttle every other client's failed logins",
|
||||||
|
)
|
||||||
|
// The warning must not claim a lockout the login
|
||||||
|
// endpoint no longer permits: credentials are verified
|
||||||
|
// before any budget is spent.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "a correct password still gets in",
|
||||||
|
)
|
||||||
|
// The text must stay accurate for a developer with
|
||||||
|
// nothing in front of the process, where an empty
|
||||||
|
// list costs nothing.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "nothing proxying to this process",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// metricsEnv describes what one subtest below puts in the
|
// metricsEnv describes what one subtest below puts in the
|
||||||
// environment for a single METRICS_ variable. A variable that is
|
// environment for a single METRICS_ variable. A variable that is
|
||||||
// set to the empty string and one that is not set at all are
|
// set to the empty string and one that is not set at all are
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ import "log/slog"
|
|||||||
// the external config_test package so each helper can be covered by
|
// the external config_test package so each helper can be covered by
|
||||||
// its own table-driven test without weakening the package API.
|
// its own table-driven test without weakening the package API.
|
||||||
|
|
||||||
|
// WarnSharedRateLimitBucketForTest loads a Config from the current
|
||||||
|
// environment and emits its startup warnings to log. The real logger
|
||||||
|
// writes to stdout, so this lets the warning's firing condition be
|
||||||
|
// asserted against a handler the test controls.
|
||||||
|
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
|
||||||
|
c, err := loadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.warnSharedRateLimitBucket(log)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// WarnEgressAllowlistForTest loads a Config from the current
|
// WarnEgressAllowlistForTest loads a Config from the current
|
||||||
// environment and emits its egress-allowlist startup warning to
|
// environment and emits its egress-allowlist startup warning to
|
||||||
// log, so a test can assert both that the warning fires only when
|
// log, so a test can assert both that the warning fires only when
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
deliveries []database.Delivery
|
deliveries []database.Delivery
|
||||||
results []database.DeliveryResult
|
results []database.DeliveryResult
|
||||||
depths []struct{ Depth int }
|
depths []struct{ Depth int }
|
||||||
|
failed struct{ Count int64 }
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
@@ -123,7 +124,7 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Order("attempt_num ASC").Find(&results),
|
Order("attempt_num ASC").Find(&results),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
// Retention's three deletes (reapExpired), whose subqueries are built
|
// Retention's deletes (deleteExpired), whose subqueries are built
|
||||||
// afresh for each statement as it builds them.
|
// afresh for each statement as it builds them.
|
||||||
expiredEventIDs := func() *gorm.DB {
|
expiredEventIDs := func() *gorm.DB {
|
||||||
return dry.Model(&database.Event{}).Select("id").
|
return dry.Model(&database.Event{}).Select("id").
|
||||||
@@ -135,6 +136,12 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
||||||
|
Select("count(CASE WHEN status = ? THEN 1 END) AS count",
|
||||||
|
database.DeliveryStatusFailed).
|
||||||
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
|
Take(&failed),
|
||||||
|
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"event_id IN (?)", expiredEventIDs(),
|
"event_id IN (?)", expiredEventIDs(),
|
||||||
).Delete(&database.Delivery{}),
|
).Delete(&database.Delivery{}),
|
||||||
@@ -144,6 +151,54 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
|
// progress, deliveries finished and events received since a time, and
|
||||||
|
// the newest event, which must come straight off an index rather than
|
||||||
|
// from sorting every event.
|
||||||
|
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(uuid.New().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
since := time.Now()
|
||||||
|
|
||||||
|
var (
|
||||||
|
count int64
|
||||||
|
newest []time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).Count(&count),
|
||||||
|
"idx_deliveries_status (status=? AND deleted_at=?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Where("status = ? AND finished_at >= ?",
|
||||||
|
database.DeliveryStatusFailed, since).Count(&count),
|
||||||
|
"idx_deliveries_status "+
|
||||||
|
"(status=? AND deleted_at=? AND finished_at>?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).Count(&count),
|
||||||
|
"idx_events_deleted_at_created_at "+
|
||||||
|
"(deleted_at=? AND created_at>?)")
|
||||||
|
|
||||||
|
newestEvent := dry.Model(&database.Event{}).
|
||||||
|
Order("created_at DESC").Limit(1).Pluck("created_at", &newest)
|
||||||
|
assertPlanUses(t, db, newestEvent,
|
||||||
|
"idx_events_deleted_at_created_at (deleted_at=?)")
|
||||||
|
assert.NotContains(t, queryPlan(t, db, newestEvent), "TEMP B-TREE")
|
||||||
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
@@ -152,6 +207,18 @@ func assertPlanUses(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
plan := queryPlan(t, db, built)
|
||||||
|
|
||||||
|
for _, index := range indexes {
|
||||||
|
assert.Contains(t, plan, index, built.Statement.SQL.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryPlan returns SQLite's plan for a statement GORM built in a dry
|
||||||
|
// run, run with the same SQL and arguments GORM would send.
|
||||||
|
func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
var plan []struct{ Detail string }
|
var plan []struct{ Detail string }
|
||||||
|
|
||||||
require.NoError(t, db.Raw(
|
require.NoError(t, db.Raw(
|
||||||
@@ -159,8 +226,5 @@ func assertPlanUses(
|
|||||||
built.Statement.Vars...,
|
built.Statement.Vars...,
|
||||||
).Scan(&plan).Error)
|
).Scan(&plan).Error)
|
||||||
|
|
||||||
for _, index := range indexes {
|
return fmt.Sprint(plan)
|
||||||
assert.Contains(t, fmt.Sprint(plan), index,
|
|
||||||
built.Statement.SQL.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -80,14 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
return dummyPasswordHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
|
||||||
// memory cost until t ends. t must not run in parallel with other
|
|
||||||
// tests, which would hash at that cost alongside it.
|
|
||||||
func HashAtShippedCostForTest(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
hashAtShippedCostInTest = true
|
|
||||||
|
|
||||||
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -45,6 +49,12 @@ type Delivery struct {
|
|||||||
// gives.
|
// gives.
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
|
// FinishedAt is when the delivery became delivered or failed, and
|
||||||
|
// nil while it is pending or retrying. It ends the status index,
|
||||||
|
// so the webhook page counts the deliveries that finished in a
|
||||||
|
// recent window by reading that window from the index.
|
||||||
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitzero"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ type Event struct {
|
|||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
|
||||||
// is stored so the recent events list can show it without reading
|
|
||||||
// the body.
|
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver, which is every event created before the column
|
// the receiver, which is every event created before the column
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Totals is the single row of running totals in a webhook's event
|
||||||
|
// database. It is what keeps the webhook page's lifetime figures right
|
||||||
|
// after retention has removed the rows they count, and what lets the
|
||||||
|
// page show them without counting every row.
|
||||||
|
//
|
||||||
|
// Storing an event, creating a delivery and failing a delivery each
|
||||||
|
// add one, and retention adds what it deletes to the Removed columns.
|
||||||
|
// Every addition goes through AddTotals, in the transaction that
|
||||||
|
// writes or deletes the rows it counts.
|
||||||
|
type Totals struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
|
||||||
|
Events int64 `gorm:"not null"`
|
||||||
|
Deliveries int64 `gorm:"not null"`
|
||||||
|
Failures int64 `gorm:"not null"`
|
||||||
|
|
||||||
|
EventsRemoved int64 `gorm:"not null"`
|
||||||
|
DeliveriesRemoved int64 `gorm:"not null"`
|
||||||
|
FailuresRemoved int64 `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddTotals updates.
|
||||||
|
func (Totals) TableName() string {
|
||||||
|
return "totals"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsWithinRetention is how many of the webhook's events are still
|
||||||
|
// stored.
|
||||||
|
func (t Totals) EventsWithinRetention() int64 {
|
||||||
|
return t.Events - t.EventsRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveriesWithinRetention is how many of the webhook's deliveries
|
||||||
|
// are still stored.
|
||||||
|
func (t Totals) DeliveriesWithinRetention() int64 {
|
||||||
|
return t.Deliveries - t.DeliveriesRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailuresWithinRetention is how many of the webhook's failed
|
||||||
|
// deliveries are still stored.
|
||||||
|
func (t Totals) FailuresWithinRetention() int64 {
|
||||||
|
return t.Failures - t.FailuresRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTotals adds each count in add to the webhook's running totals.
|
||||||
|
// Call it on the transaction that writes or deletes the rows it
|
||||||
|
// counts, so the totals change exactly when those rows do.
|
||||||
|
func AddTotals(tx *gorm.DB, add Totals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`UPDATE totals SET
|
||||||
|
events = events + ?,
|
||||||
|
deliveries = deliveries + ?,
|
||||||
|
failures = failures + ?,
|
||||||
|
events_removed = events_removed + ?,
|
||||||
|
deliveries_removed = deliveries_removed + ?,
|
||||||
|
failures_removed = failures_removed + ?`,
|
||||||
|
add.Events, add.Deliveries, add.Failures,
|
||||||
|
add.EventsRemoved, add.DeliveriesRemoved, add.FailuresRemoved,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding to running totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package database
|
|||||||
|
|
||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
// Event-tier models (Event, Delivery, DeliveryResult, Totals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
// HashPassword generates an Argon2id hash of the password
|
||||||
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
|
||||||
// that starts a database hashes the bootstrap admin password, dozens
|
|
||||||
// of them run in parallel, and under the race detector each 64 MB hash
|
|
||||||
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
|
||||||
// checks, so verification follows.
|
|
||||||
const testArgon2Memory = 1024
|
|
||||||
|
|
||||||
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
|
||||||
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // set by one test, see above
|
|
||||||
var hashAtShippedCostInTest bool
|
|
||||||
|
|
||||||
// HashPassword generates an Argon2id hash of the password. A binary
|
|
||||||
// built by go test hashes at testArgon2Memory; one built by go build
|
|
||||||
// always hashes at the defaults.
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
config := DefaultPasswordConfig()
|
||||||
|
|
||||||
if testing.Testing() && !hashAtShippedCostInTest {
|
|
||||||
config.Memory = testArgon2Memory
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
|
|
||||||
|
|||||||
@@ -192,39 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHashPassword_ShippedParameters hashes and verifies through
|
|
||||||
// HashPassword at the shipped Argon2id parameters. Every other test
|
|
||||||
// hashes at the lower memory cost a test binary uses, so this is the
|
|
||||||
// one that keeps production hashing covered. One hash and one
|
|
||||||
// verification: each costs 64 MB.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the hashing cost for the whole binary
|
|
||||||
func TestHashPassword_ShippedParameters(t *testing.T) {
|
|
||||||
database.HashAtShippedCostForTest(t)
|
|
||||||
|
|
||||||
password := "correct horse battery staple"
|
|
||||||
|
|
||||||
hash, err := database.HashPassword(password)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hashing with the shipped parameters: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
|
||||||
|
|
||||||
if !strings.HasPrefix(hash, shipped) {
|
|
||||||
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, err := database.VerifyPassword(password, hash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("VerifyPassword() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
t.Error("VerifyPassword() returned false for correct password")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// dummy hash so that a nonexistent account is not answered in
|
||||||
|
|||||||
@@ -267,55 +267,101 @@ func retentionCutoff(
|
|||||||
|
|
||||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||||
// results, deliveries, and events associated with events older than
|
// results, deliveries, and events associated with events older than
|
||||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
// cutoff, and adds what it deleted to the running totals, all in one
|
||||||
// than soft-deleted, reclaiming disk. It returns the number of events
|
// transaction. Deletes are unscoped so rows are physically removed
|
||||||
// deleted.
|
// rather than soft-deleted, reclaiming disk. It returns the number of
|
||||||
|
// events deleted.
|
||||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
|
var removed Totals
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
removed, err = deleteExpired(tx, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddTotals(tx, removed)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed.EventsRemoved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteExpired runs reapExpired's deletes and returns how many
|
||||||
|
// events, deliveries and failed deliveries they removed.
|
||||||
|
func deleteExpired(tx *gorm.DB, cutoff time.Time) (Totals, error) {
|
||||||
|
var removed Totals
|
||||||
|
|
||||||
// Fresh subqueries are built per statement to avoid reusing a
|
// Fresh subqueries are built per statement to avoid reusing a
|
||||||
// mutated builder across executions.
|
// mutated builder across executions.
|
||||||
expiredEventIDs := func() *gorm.DB {
|
expiredEventIDs := func() *gorm.DB {
|
||||||
return db.Model(&Event{}).
|
return tx.Model(&Event{}).
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("created_at < ?", cutoff)
|
Where("created_at < ?", cutoff)
|
||||||
}
|
}
|
||||||
expiredDeliveryIDs := func() *gorm.DB {
|
expiredDeliveryIDs := func() *gorm.DB {
|
||||||
return db.Model(&Delivery{}).
|
return tx.Model(&Delivery{}).
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("event_id IN (?)", expiredEventIDs())
|
Where("event_id IN (?)", expiredEventIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Delivery results whose delivery belongs to an expired event.
|
// 1. Delivery results whose delivery belongs to an expired event.
|
||||||
res := db.Unscoped().
|
res := tx.Unscoped().
|
||||||
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||||
Delete(&DeliveryResult{})
|
Delete(&DeliveryResult{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return 0, fmt.Errorf(
|
return removed, fmt.Errorf(
|
||||||
"deleting expired delivery results: %w",
|
"deleting expired delivery results: %w",
|
||||||
res.Error,
|
res.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Deliveries belonging to an expired event.
|
// 2. Deliveries belonging to an expired event, after counting the
|
||||||
del := db.Unscoped().
|
// failed ones among them. The status is tested in the select list
|
||||||
|
// rather than the WHERE clause: there, SQLite would read every
|
||||||
|
// failed delivery the webhook has through the status index,
|
||||||
|
// instead of only the expired ones through the event_id index.
|
||||||
|
var failed struct{ Count int64 }
|
||||||
|
|
||||||
|
err := tx.Unscoped().Model(&Delivery{}).
|
||||||
|
Select("count(CASE WHEN status = ? THEN 1 END) AS count",
|
||||||
|
DeliveryStatusFailed).
|
||||||
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
|
Take(&failed).Error
|
||||||
|
if err != nil {
|
||||||
|
return removed, fmt.Errorf(
|
||||||
|
"counting expired failed deliveries: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
del := tx.Unscoped().
|
||||||
Where("event_id IN (?)", expiredEventIDs()).
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
Delete(&Delivery{})
|
Delete(&Delivery{})
|
||||||
if del.Error != nil {
|
if del.Error != nil {
|
||||||
return 0, fmt.Errorf(
|
return removed, fmt.Errorf(
|
||||||
"deleting expired deliveries: %w",
|
"deleting expired deliveries: %w",
|
||||||
del.Error,
|
del.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The expired events themselves.
|
// 3. The expired events themselves.
|
||||||
ev := db.Unscoped().
|
ev := tx.Unscoped().
|
||||||
Where("created_at < ?", cutoff).
|
Where("created_at < ?", cutoff).
|
||||||
Delete(&Event{})
|
Delete(&Event{})
|
||||||
if ev.Error != nil {
|
if ev.Error != nil {
|
||||||
return 0, fmt.Errorf(
|
return removed, fmt.Errorf(
|
||||||
"deleting expired events: %w",
|
"deleting expired events: %w",
|
||||||
ev.Error,
|
ev.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ev.RowsAffected, nil
|
removed.EventsRemoved = ev.RowsAffected
|
||||||
|
removed.DeliveriesRemoved = del.RowsAffected
|
||||||
|
removed.FailuresRemoved = failed.Count
|
||||||
|
|
||||||
|
return removed, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readTotals reads a webhook database's row of running totals,
|
||||||
|
// asserting that it has exactly one.
|
||||||
|
func readTotals(t *testing.T, db *gorm.DB) database.Totals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.Totals
|
||||||
|
|
||||||
|
require.NoError(t, db.Find(&rows).Error)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_TotalsRowSurvivesReopen verifies that a new
|
||||||
|
// event database starts with one row of zero totals, and that opening
|
||||||
|
// it again keeps that row and what was added to it.
|
||||||
|
func TestWebhookDBManager_TotalsRowSurvivesReopen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fresh := readTotals(t, db)
|
||||||
|
assert.Equal(t, database.Totals{ID: fresh.ID}, fresh)
|
||||||
|
|
||||||
|
require.NoError(t, database.AddTotals(db, database.Totals{
|
||||||
|
Events: 2, Deliveries: 3, Failures: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Drop the cached connection so the next open reopens the file,
|
||||||
|
// as a restart would.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
db, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, database.Totals{
|
||||||
|
ID: fresh.ID, Events: 2, Deliveries: 3, Failures: 1,
|
||||||
|
}, readTotals(t, db))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetentionReaper_AddsWhatItRemovesToTotals verifies that a sweep
|
||||||
|
// leaves the lifetime totals alone and adds the events, deliveries and
|
||||||
|
// failed deliveries it deletes to the removed totals, so the totals
|
||||||
|
// within retention match the rows still stored.
|
||||||
|
func TestRetentionReaper_AddsWhatItRemovesToTotals(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
expired := now.Add(-40 * 24 * time.Hour)
|
||||||
|
|
||||||
|
seedEventChain(t, db, webhookID, expired)
|
||||||
|
expiredFailure := seedEventChain(t, db, webhookID, expired)
|
||||||
|
recentFailure := seedEventChain(
|
||||||
|
t, db, webhookID, now.Add(-24*time.Hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, id := range []string{
|
||||||
|
expiredFailure.deliveryID, recentFailure.deliveryID,
|
||||||
|
} {
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("status", database.DeliveryStatusFailed).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The totals storing those rows would have left.
|
||||||
|
require.NoError(t, database.AddTotals(db, database.Totals{
|
||||||
|
Events: 3, Deliveries: 3, Failures: 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
totals := readTotals(t, db)
|
||||||
|
assert.Equal(t, database.Totals{
|
||||||
|
ID: totals.ID,
|
||||||
|
Events: 3, Deliveries: 3, Failures: 2,
|
||||||
|
EventsRemoved: 2, DeliveriesRemoved: 2, FailuresRemoved: 1,
|
||||||
|
}, totals)
|
||||||
|
|
||||||
|
var events, deliveries, failures int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Count(&deliveries).Error)
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("status = ?", database.DeliveryStatusFailed).
|
||||||
|
Count(&failures).Error)
|
||||||
|
|
||||||
|
assert.Equal(t, events, totals.EventsWithinRetention())
|
||||||
|
assert.Equal(t, deliveries, totals.DeliveriesWithinRetention())
|
||||||
|
assert.Equal(t, failures, totals.FailuresWithinRetention())
|
||||||
|
|
||||||
|
// A sweep with nothing left to remove changes nothing.
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, totals, readTotals(t, db))
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, and DeliveryResults.
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
|
// running Totals of them.
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -294,7 +295,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
|
|
||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{}, &Totals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -305,6 +306,17 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new database gets its row of running totals, all zero.
|
||||||
|
err = db.FirstOrCreate(&Totals{}).Error
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"creating running totals for webhook database %s: %w",
|
||||||
|
webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
"opened per-webhook database",
|
"opened per-webhook database",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failureTotal reads the running failure total of a webhook database.
|
||||||
|
func failureTotal(t *testing.T, db *gorm.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var totals database.Totals
|
||||||
|
|
||||||
|
require.NoError(t, db.Take(&totals).Error)
|
||||||
|
|
||||||
|
return totals.Failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_FinishTimeAndFailureTotal pins what a status
|
||||||
|
// write records for the webhook page's statistics: the time a delivery
|
||||||
|
// finished, set only when it becomes delivered or failed, and one more
|
||||||
|
// on the failure total when it fails.
|
||||||
|
func TestUpdateDeliveryStatus_FinishTimeAndFailureTotal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
status database.DeliveryStatus
|
||||||
|
finished bool
|
||||||
|
failures int64
|
||||||
|
}{
|
||||||
|
{database.DeliveryStatusRetrying, false, 0},
|
||||||
|
{database.DeliveryStatusDelivered, true, 0},
|
||||||
|
{database.DeliveryStatusFailed, true, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.status), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, uuid.New().String(),
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, tt.status,
|
||||||
|
))
|
||||||
|
|
||||||
|
var stored database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||||
|
assert.Equal(t, tt.status, stored.Status)
|
||||||
|
|
||||||
|
if tt.finished {
|
||||||
|
require.NotNil(t, stored.FinishedAt)
|
||||||
|
assert.False(t, stored.FinishedAt.Before(before))
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, stored.FinishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.failures, failureTotal(t, db))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
||||||
|
// delivery retention deleted while the engine still held it. Failing
|
||||||
|
// it afterwards writes no row, so it adds no failure either: retention
|
||||||
|
// has already counted what it removed.
|
||||||
|
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, uuid.New().String(),
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.Unscoped().
|
||||||
|
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, database.DeliveryStatusFailed,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Zero(t, failureTotal(t, db))
|
||||||
|
}
|
||||||
@@ -1554,8 +1554,9 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Model(d).
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
Update("status", status).Error
|
return writeDeliveryStatus(tx, d, status)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"updating delivery %s to status %s: %w",
|
"updating delivery %s to status %s: %w",
|
||||||
@@ -1574,6 +1575,33 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
||||||
|
// becomes delivered or failed also gets the time it finished, and a
|
||||||
|
// failed one is added to the webhook's running failure total. The
|
||||||
|
// failure is counted only if the row was still there to update:
|
||||||
|
// retention may have deleted it while the engine was working on it.
|
||||||
|
func writeDeliveryStatus(
|
||||||
|
tx *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
columns := map[string]any{"status": status}
|
||||||
|
if status.Terminal() {
|
||||||
|
columns["finished_at"] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
res := tx.Model(d).Updates(columns)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == database.DeliveryStatusFailed && res.RowsAffected > 0 {
|
||||||
|
return database.AddTotals(tx, database.Totals{Failures: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// settleStatus moves a delivery to its outcome status and reports a
|
// settleStatus moves a delivery to its outcome status and reports a
|
||||||
// failed write through bookkeepingFailed, which leaves the row
|
// failed write through bookkeepingFailed, which leaves the row
|
||||||
// recoverable. It exists so the target call sites read as one
|
// recoverable. It exists so the target call sites read as one
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
|
&database.Totals{},
|
||||||
))
|
))
|
||||||
|
require.NoError(t, db.Create(&database.Totals{}).Error)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
||||||
|
// target type, so no metric moves.
|
||||||
|
func (e *Engine) ExportUpdateDeliveryStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportProcessNewTask exposes processNewTask.
|
// ExportProcessNewTask exposes processNewTask.
|
||||||
func (e *Engine) ExportProcessNewTask(
|
func (e *Engine) ExportProcessNewTask(
|
||||||
ctx context.Context, task *Task,
|
ctx context.Context, task *Task,
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ var (
|
|||||||
// permit specific blocks out of this set with
|
// permit specific blocks out of this set with
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// A public address belongs on the default blocklist only if it
|
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything. A
|
|
||||||
// provider's other public addresses are not refused, since
|
|
||||||
// reaching them can be legitimate and no list of them could be
|
|
||||||
// complete.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
|
|||||||
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
|
|||||||
// The credential check runs BEFORE any rate-limit budget is
|
// The credential check runs BEFORE any rate-limit budget is
|
||||||
// consulted, and only a failed check spends budget. That is what
|
// consulted, and only a failed check spends budget. That is what
|
||||||
// keeps the single administrative path reachable: behind the reverse
|
// keeps the single administrative path reachable: behind the reverse
|
||||||
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
||||||
// it, every client shares one bucket, so a limiter spent on arrival
|
// client shares one bucket, so a limiter spent on arrival lets any
|
||||||
// lets any stranger deny the operator's own correct password
|
// stranger deny the operator's own correct password indefinitely.
|
||||||
// indefinitely.
|
|
||||||
//
|
//
|
||||||
// Verifying first means every login POST costs an Argon2id hash, so
|
// Verifying first means every login POST costs an Argon2id hash, so
|
||||||
// the work is taken under a bounded number of verification slots.
|
// the work is taken under a bounded number of verification slots.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
|
|
||||||
// sharedProxyPeer is the whole point of this file. Production is
|
// sharedProxyPeer is the whole point of this file. Production is
|
||||||
// required to run behind a TLS-terminating reverse proxy, and
|
// required to run behind a TLS-terminating reverse proxy, and
|
||||||
// when TRUSTED_PROXIES does not cover it every client — attacker
|
// TRUSTED_PROXIES defaults to empty, so every client — attacker
|
||||||
// and operator alike — reaches the process from the proxy's
|
// and operator alike — reaches the process from the proxy's
|
||||||
// address and shares one rate-limit bucket. Both parties in
|
// address and shares one rate-limit bucket. Both parties in
|
||||||
// these tests therefore use the same RemoteAddr.
|
// these tests therefore use the same RemoteAddr.
|
||||||
@@ -115,11 +115,11 @@ func floodFailures(
|
|||||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||||
//
|
//
|
||||||
// The attacker and the operator share one rate-limit bucket, because
|
// The attacker and the operator share one rate-limit bucket, because
|
||||||
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
||||||
// cover it, every client keys on the proxy's address. The attacker
|
// client keys on the proxy's address. The attacker floods the
|
||||||
// floods the operator's own username — a single-admin product has a
|
// operator's own username — a single-admin product has a predictable
|
||||||
// predictable one — far past the failure limit. The operator must
|
// one — far past the failure limit. The operator must still be able
|
||||||
// still be able to log in with the correct password.
|
// to log in with the correct password.
|
||||||
//
|
//
|
||||||
// This fails if credentials stop being verified ahead of the limiter.
|
// This fails if credentials stop being verified ahead of the limiter.
|
||||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||||
|
|||||||
@@ -299,7 +299,8 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row and returns
|
// createReplayDelivery writes the new pending delivery row, adds it to
|
||||||
|
// the webhook's running totals in the same transaction, and returns
|
||||||
// the task that carries it to the delivery engine.
|
// the task that carries it to the delivery engine.
|
||||||
//
|
//
|
||||||
// The row is written with associations omitted, and neither Event nor
|
// The row is written with associations omitted, and neither Event nor
|
||||||
@@ -319,7 +320,14 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Omit(clause.Associations).Create(dlv).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTotals(tx, database.Totals{Deliveries: 1})
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ func assertEventCopy(
|
|||||||
assert.Equal(t, original.Method, fresh.Method)
|
assert.Equal(t, original.Method, fresh.Method)
|
||||||
assert.Equal(t, original.Headers, fresh.Headers)
|
assert.Equal(t, original.Headers, fresh.Headers)
|
||||||
assert.Equal(t, original.Body, fresh.Body)
|
assert.Equal(t, original.Body, fresh.Body)
|
||||||
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
|
|
||||||
assert.Equal(t, original.ContentType, fresh.ContentType)
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
||||||
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
||||||
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookStatsForTest returns the figures the statistics pane on a
|
||||||
|
// webhook's page shows, from the webhook's entrypoints and targets
|
||||||
|
// loaded as that page loads them.
|
||||||
|
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
||||||
|
|
||||||
|
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of recent events to show.
|
// recentEventLimit is the number of recent events to show.
|
||||||
recentEventLimit = 50
|
recentEventLimit = 20
|
||||||
// paginationPerPage is the number of items per page.
|
// paginationPerPage is the number of items per page.
|
||||||
paginationPerPage = 25
|
paginationPerPage = 25
|
||||||
|
|
||||||
@@ -91,18 +91,22 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates. The page file must be
|
// base, htmlheader, and navbar templates, and with any further files
|
||||||
// listed first so that its root action ({{template "base" .}})
|
// the page includes. The page file must be listed first so that its
|
||||||
// becomes the template set's entry point.
|
// root action ({{template "base" .}}) becomes the template set's entry
|
||||||
func parsePageTemplate(pageFile string) *template.Template {
|
// point.
|
||||||
|
func parsePageTemplate(
|
||||||
|
pageFile string, included ...string,
|
||||||
|
) *template.Template {
|
||||||
|
files := append([]string{
|
||||||
|
pageFile,
|
||||||
|
"base.html",
|
||||||
|
"htmlheader.html",
|
||||||
|
"navbar.html",
|
||||||
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
template.ParseFS(
|
template.ParseFS(templates.Templates, files...),
|
||||||
templates.Templates,
|
|
||||||
pageFile,
|
|
||||||
"base.html",
|
|
||||||
"htmlheader.html",
|
|
||||||
"navbar.html",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ func New(
|
|||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
|
|||||||
h.HandleProfile().ServeHTTP(w, req)
|
h.HandleProfile().ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "Account Information")
|
|
||||||
assert.NotContains(t, w.Body.String(), "Account Type")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// recentEventColumns is the recent events list's projection. It
|
|
||||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
|
||||||
// and reads its size from body_bytes, recorded when the event was
|
|
||||||
// stored.
|
|
||||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
|
||||||
"resubmitted_from_id, body_bytes"
|
|
||||||
|
|
||||||
// recentAttemptColumns is the part of a recorded attempt the list
|
|
||||||
// uses. The event log's deliveryResultColumns also reads response
|
|
||||||
// bodies, which the list does not show.
|
|
||||||
const recentAttemptColumns = "delivery_id, status_code, created_at"
|
|
||||||
|
|
||||||
// RecentEventView is one row of the recent events list on a
|
|
||||||
// webhook's page.
|
|
||||||
type RecentEventView struct {
|
|
||||||
Method string
|
|
||||||
ContentType string
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from,
|
|
||||||
// empty for an event that arrived on the receiver.
|
|
||||||
ResubmittedFromID string
|
|
||||||
|
|
||||||
// Received is how long ago the event arrived, and ReceivedUTC
|
|
||||||
// the full timestamp the page shows on hover.
|
|
||||||
Received string
|
|
||||||
ReceivedUTC string
|
|
||||||
|
|
||||||
// Size is the size of the stored body.
|
|
||||||
Size string
|
|
||||||
|
|
||||||
// ProcessingTime is how long the event's slowest delivery
|
|
||||||
// took; see processingTime.
|
|
||||||
ProcessingTime string
|
|
||||||
|
|
||||||
// Status is what the webhook's HTTP target answered, and
|
|
||||||
// StatusClass its colour; see targetStatus. Both are empty
|
|
||||||
// unless the webhook has exactly one HTTP target.
|
|
||||||
Status string
|
|
||||||
StatusClass string
|
|
||||||
}
|
|
||||||
|
|
||||||
// recentEventRow is one row of recentEventColumns.
|
|
||||||
type recentEventRow struct {
|
|
||||||
ID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
Method string
|
|
||||||
ContentType string
|
|
||||||
ResubmittedFromID *string
|
|
||||||
BodyBytes uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
|
||||||
// when the attempt's result was recorded, which is when the attempt
|
|
||||||
// finished.
|
|
||||||
type recentAttemptRow struct {
|
|
||||||
DeliveryID string
|
|
||||||
StatusCode int
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
|
||||||
// when it has exactly one, and "" when it has none or several.
|
|
||||||
func singleHTTPTargetID(targets []database.Target) string {
|
|
||||||
id := ""
|
|
||||||
count := 0
|
|
||||||
|
|
||||||
for i := range targets {
|
|
||||||
if targets[i].Type == database.TargetTypeHTTP {
|
|
||||||
id = targets[i].ID
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadRecentEvents loads the webhook's recentEventLimit newest
|
|
||||||
// events for its page, newest first. statusTargetID is the
|
|
||||||
// webhook's only HTTP target, or "" when the list shows no status.
|
|
||||||
func loadRecentEvents(
|
|
||||||
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
|
||||||
) ([]RecentEventView, error) {
|
|
||||||
var rows []recentEventRow
|
|
||||||
|
|
||||||
err := webhookDB.Model(&database.Event{}).
|
|
||||||
Select(recentEventColumns).
|
|
||||||
Where("webhook_id = ?", webhookID).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Limit(recentEventLimit).
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
eventIDs := make([]string, len(rows))
|
|
||||||
for i := range rows {
|
|
||||||
eventIDs[i] = rows[i].ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest first, so an event's last delivery to a target is its
|
|
||||||
// newest: a replay adds a delivery rather than changing the
|
|
||||||
// earlier one.
|
|
||||||
var deliveries []database.Delivery
|
|
||||||
|
|
||||||
err = webhookDB.
|
|
||||||
Select("id, event_id, target_id, status, created_at").
|
|
||||||
Where("event_id IN ?", eventIDs).
|
|
||||||
Order("created_at ASC").
|
|
||||||
Find(&deliveries).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
byEvent := make(map[string][]database.Delivery, len(rows))
|
|
||||||
deliveryIDs := make([]string, len(deliveries))
|
|
||||||
|
|
||||||
for i := range deliveries {
|
|
||||||
eventID := deliveries[i].EventID
|
|
||||||
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
|
||||||
deliveryIDs[i] = deliveries[i].ID
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
views := make([]RecentEventView, len(rows))
|
|
||||||
for i := range rows {
|
|
||||||
views[i] = rows[i].view(
|
|
||||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return views, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadRecentAttempts loads the recorded attempts of the listed
|
|
||||||
// events' deliveries, keyed by delivery ID, each delivery's in
|
|
||||||
// attempt order. The IDs go in chunks for the reason
|
|
||||||
// deliveryIDChunkSize gives.
|
|
||||||
func loadRecentAttempts(
|
|
||||||
webhookDB *gorm.DB, deliveryIDs []string,
|
|
||||||
) (map[string][]recentAttemptRow, error) {
|
|
||||||
byDelivery := make(map[string][]recentAttemptRow)
|
|
||||||
|
|
||||||
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
|
|
||||||
var rows []recentAttemptRow
|
|
||||||
|
|
||||||
err := webhookDB.Model(&database.DeliveryResult{}).
|
|
||||||
Select(recentAttemptColumns).
|
|
||||||
Where("delivery_id IN ?", chunk).
|
|
||||||
Order("attempt_num ASC").
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range rows {
|
|
||||||
id := rows[i].DeliveryID
|
|
||||||
byDelivery[id] = append(byDelivery[id], rows[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return byDelivery, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// view projects a loaded row for rendering. deliveries is the
|
|
||||||
// event's deliveries, oldest first, and attempts their recorded
|
|
||||||
// attempts keyed by delivery ID.
|
|
||||||
func (r *recentEventRow) view(
|
|
||||||
deliveries []database.Delivery,
|
|
||||||
attempts map[string][]recentAttemptRow,
|
|
||||||
statusTargetID string,
|
|
||||||
) RecentEventView {
|
|
||||||
v := RecentEventView{
|
|
||||||
Method: r.Method,
|
|
||||||
ContentType: r.ContentType,
|
|
||||||
Received: humanize.Time(r.CreatedAt),
|
|
||||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
|
||||||
Size: humanize.Bytes(r.BodyBytes),
|
|
||||||
ProcessingTime: processingTime(deliveries, attempts),
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.ResubmittedFromID != nil {
|
|
||||||
v.ResubmittedFromID = *r.ResubmittedFromID
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusTargetID != "" {
|
|
||||||
v.Status, v.StatusClass = targetStatus(
|
|
||||||
deliveries, attempts, statusTargetID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// processingTime is how long the event's slowest delivery took,
|
|
||||||
// from being queued to its last recorded attempt, time spent
|
|
||||||
// waiting between retries included. A delivery is queued when its
|
|
||||||
// event is received, or when an operator replays it, so a replay
|
|
||||||
// is timed from the replay rather than from the event's arrival.
|
|
||||||
// It is "in progress" while any delivery is pending or retrying,
|
|
||||||
// and empty for an event with no deliveries.
|
|
||||||
func processingTime(
|
|
||||||
deliveries []database.Delivery,
|
|
||||||
attempts map[string][]recentAttemptRow,
|
|
||||||
) string {
|
|
||||||
if len(deliveries) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var slowest time.Duration
|
|
||||||
|
|
||||||
for i := range deliveries {
|
|
||||||
if !deliveries[i].Status.Terminal() {
|
|
||||||
return "in progress"
|
|
||||||
}
|
|
||||||
|
|
||||||
tries := attempts[deliveries[i].ID]
|
|
||||||
if len(tries) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
last := tries[len(tries)-1].CreatedAt
|
|
||||||
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
return slowest.Round(time.Millisecond).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetStatus is what the target answered for the event, and the
|
|
||||||
// colour to show it in: the HTTP status code of the last attempt of
|
|
||||||
// the event's newest delivery to the target. Without a code it is
|
|
||||||
// "no response" when that attempt failed before a response
|
|
||||||
// arrived, the delivery's status ("pending") before any attempt,
|
|
||||||
// and "not sent" when the event has no delivery to the target.
|
|
||||||
func targetStatus(
|
|
||||||
deliveries []database.Delivery,
|
|
||||||
attempts map[string][]recentAttemptRow,
|
|
||||||
targetID string,
|
|
||||||
) (string, string) {
|
|
||||||
newest := -1
|
|
||||||
|
|
||||||
for i := range deliveries {
|
|
||||||
if deliveries[i].TargetID == targetID {
|
|
||||||
newest = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if newest < 0 {
|
|
||||||
return "not sent", "text-gray-400"
|
|
||||||
}
|
|
||||||
|
|
||||||
tries := attempts[deliveries[newest].ID]
|
|
||||||
if len(tries) == 0 {
|
|
||||||
return string(deliveries[newest].Status), "text-gray-400"
|
|
||||||
}
|
|
||||||
|
|
||||||
code := tries[len(tries)-1].StatusCode
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case code == 0:
|
|
||||||
return "no response", "text-red-600"
|
|
||||||
case code >= http.StatusInternalServerError:
|
|
||||||
return strconv.Itoa(code), "text-red-600"
|
|
||||||
case code >= http.StatusBadRequest:
|
|
||||||
return strconv.Itoa(code), "text-yellow-600"
|
|
||||||
case code >= http.StatusMultipleChoices:
|
|
||||||
return strconv.Itoa(code), "text-gray-500"
|
|
||||||
case code >= http.StatusOK:
|
|
||||||
return strconv.Itoa(code), "text-green-600"
|
|
||||||
default:
|
|
||||||
return strconv.Itoa(code), "text-gray-500"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// statusTitle marks the status column's cell in a recent events
|
|
||||||
// row; it is absent from the page when the column is not shown.
|
|
||||||
const statusTitle = `title="HTTP status from the HTTP target"`
|
|
||||||
|
|
||||||
// recentEventsFixture is one started app and a webhook whose
|
|
||||||
// recent events list a test fills.
|
|
||||||
type recentEventsFixture struct {
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
webhook *database.Webhook
|
|
||||||
webhookDB *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
f := &recentEventsFixture{}
|
|
||||||
|
|
||||||
var dbMgr *database.WebhookDBManager
|
|
||||||
|
|
||||||
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
f.webhook = seedWebhook(t, f.db)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
f.webhookDB = webhookDB
|
|
||||||
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *recentEventsFixture) render(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// event records an event received at receivedAt, with its body's
|
|
||||||
// size as the receiver records it.
|
|
||||||
func (f *recentEventsFixture) event(
|
|
||||||
t *testing.T, contentType, body string, receivedAt time.Time,
|
|
||||||
) *database.Event {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
WebhookID: f.webhook.ID,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: body,
|
|
||||||
BodyBytes: int64(len(body)),
|
|
||||||
ContentType: contentType,
|
|
||||||
}
|
|
||||||
event.CreatedAt = receivedAt
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Omit(
|
|
||||||
clause.Associations,
|
|
||||||
).Create(event).Error)
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// delivery records a delivery of the event to the target, queued
|
|
||||||
// when the event was received.
|
|
||||||
func (f *recentEventsFixture) delivery(
|
|
||||||
t *testing.T,
|
|
||||||
event *database.Event,
|
|
||||||
targetID string,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) *database.Delivery {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return f.deliveryQueuedAt(
|
|
||||||
t, event, targetID, status, event.CreatedAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliveryQueuedAt records a delivery of the event to the target,
|
|
||||||
// queued at queuedAt, as a replay is.
|
|
||||||
func (f *recentEventsFixture) deliveryQueuedAt(
|
|
||||||
t *testing.T,
|
|
||||||
event *database.Event,
|
|
||||||
targetID string,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
queuedAt time.Time,
|
|
||||||
) *database.Delivery {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dlv := &database.Delivery{
|
|
||||||
EventID: event.ID,
|
|
||||||
TargetID: targetID,
|
|
||||||
Status: status,
|
|
||||||
}
|
|
||||||
dlv.CreatedAt = queuedAt
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Omit(
|
|
||||||
clause.Associations,
|
|
||||||
).Create(dlv).Error)
|
|
||||||
|
|
||||||
return dlv
|
|
||||||
}
|
|
||||||
|
|
||||||
// attempt records one attempt of the delivery that finished took
|
|
||||||
// after the delivery was queued, with HTTP status code (0 for no
|
|
||||||
// response).
|
|
||||||
func (f *recentEventsFixture) attempt(
|
|
||||||
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
result := &database.DeliveryResult{
|
|
||||||
DeliveryID: dlv.ID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
StatusCode: code,
|
|
||||||
}
|
|
||||||
result.CreatedAt = dlv.CreatedAt.Add(took)
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.Omit(
|
|
||||||
clause.Associations,
|
|
||||||
).Create(result).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusCell is the status column's cell as the page renders it.
|
|
||||||
func statusCell(class, text string) string {
|
|
||||||
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
|
||||||
`>` + text + `</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
|
|
||||||
// headed "50 Most Recent Events" and holds the 50 newest events,
|
|
||||||
// newest first, and not one more.
|
|
||||||
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
base := time.Now().Add(-time.Hour)
|
|
||||||
|
|
||||||
for i := range 51 {
|
|
||||||
f.event(
|
|
||||||
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
|
|
||||||
base.Add(time.Duration(i)*time.Second),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
|
|
||||||
assert.Contains(t, body, ">50 Most Recent Events</h2>")
|
|
||||||
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
|
||||||
assert.NotContains(t, body, "application/x-recent-00")
|
|
||||||
assert.Contains(t, body, "application/x-recent-01")
|
|
||||||
assert.Less(
|
|
||||||
t,
|
|
||||||
strings.Index(body, "application/x-recent-50"),
|
|
||||||
strings.Index(body, "application/x-recent-49"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
|
||||||
// time relative with the UTC timestamp on hover, its body size,
|
|
||||||
// and its processing time once every delivery has finished.
|
|
||||||
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
receivedAt := time.Now().Add(-210 * time.Second).
|
|
||||||
UTC().Truncate(time.Second)
|
|
||||||
|
|
||||||
done := f.event(
|
|
||||||
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
|
||||||
)
|
|
||||||
f.attempt(
|
|
||||||
t,
|
|
||||||
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
|
||||||
0, 1500*time.Millisecond,
|
|
||||||
)
|
|
||||||
|
|
||||||
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
|
||||||
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body,
|
|
||||||
`<span title="`+receivedAt.Format(time.DateTime)+
|
|
||||||
` UTC">3 minutes ago</span>`,
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
|
||||||
assert.Contains(t, body, ">1.5s</span>")
|
|
||||||
assert.Contains(t, body, ">in progress</span>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
|
||||||
// webhook with exactly one HTTP target shows, colour-coded, what
|
|
||||||
// that target answered for each event. The log target beside it
|
|
||||||
// does not count against "exactly one".
|
|
||||||
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
||||||
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
for _, code := range []int{204, 302, 404, 503, 0} {
|
|
||||||
dlv := f.delivery(
|
|
||||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
)
|
|
||||||
f.attempt(t, dlv, code, time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.delivery(
|
|
||||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
f.event(t, contentTypeJSON, "{}", now)
|
|
||||||
|
|
||||||
// A replay is a newer delivery, and its answer is the one shown.
|
|
||||||
replayed := f.event(t, contentTypeJSON, "{}", now)
|
|
||||||
f.attempt(t, f.delivery(
|
|
||||||
t, replayed, target.ID, database.DeliveryStatusFailed,
|
|
||||||
), 502, time.Second)
|
|
||||||
f.attempt(t, f.deliveryQueuedAt(
|
|
||||||
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
|
||||||
now.Add(time.Minute),
|
|
||||||
), 200, time.Second)
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
|
|
||||||
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
|
||||||
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
|
||||||
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
|
||||||
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
|
||||||
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
|
||||||
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
|
||||||
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
|
||||||
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
|
||||||
assert.NotContains(t, body, ">502<")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
|
||||||
// status column is absent when the webhook has no HTTP target or
|
|
||||||
// more than one.
|
|
||||||
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := map[string][]database.TargetType{
|
|
||||||
"none": {database.TargetTypeLog},
|
|
||||||
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, types := range cases {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
|
||||||
|
|
||||||
for _, tt := range types {
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
|
||||||
f.attempt(t, f.delivery(
|
|
||||||
t, event, target.ID,
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
), 200, time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := f.render(t)
|
|
||||||
|
|
||||||
assert.Contains(t, body, `title="Body size"`)
|
|
||||||
assert.NotContains(t, body, statusTitle)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
|
||||||
// body's size in bytes, not characters, with the event it stores.
|
|
||||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
seedEntrypoint(t, f.db, f.webhook.ID)
|
|
||||||
|
|
||||||
// Two bytes per character.
|
|
||||||
body := strings.Repeat("é", 1024)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
|
|
||||||
rctx := chi.NewRouteContext()
|
|
||||||
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
|
||||||
|
|
||||||
req = req.WithContext(context.WithValue(
|
|
||||||
req.Context(), chi.RouteCtxKey, rctx,
|
|
||||||
))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
f.h.HandleWebhook().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var stored database.Event
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.First(&stored).Error)
|
|
||||||
assert.Equal(t, int64(2048), stored.BodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
|
||||||
// list cannot be loaded the page answers with an error, rather than
|
|
||||||
// an empty list claiming the webhook has no events.
|
|
||||||
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
||||||
|
|
||||||
f.attempt(t, f.delivery(
|
|
||||||
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
), 200, time.Second)
|
|
||||||
|
|
||||||
// The attempts are the list's last query, so its events and
|
|
||||||
// deliveries have already loaded when it fails.
|
|
||||||
require.NoError(t, f.webhookDB.Exec(
|
|
||||||
"DROP TABLE delivery_results",
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
|
||||||
}
|
|
||||||
@@ -62,23 +62,6 @@ func renderSourceDetailPage(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
w := serveSourceDetailPage(t, h, sess, webhookID)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveSourceDetailPage runs the real source detail handler for a
|
|
||||||
// webhook and returns its response, whatever its status.
|
|
||||||
func serveSourceDetailPage(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
webhookID string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
@@ -104,7 +87,9 @@ func serveSourceDetailPage(
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.HandleSourceDetail().ServeHTTP(w, req)
|
h.HandleSourceDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
return w
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
||||||
|
|||||||
@@ -415,23 +415,16 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Find(&targets)
|
).Find(&targets)
|
||||||
|
|
||||||
var events []RecentEventView
|
var events []database.Event
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if dbErr == nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
webhookDB.Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
return
|
).Order("created_at DESC").Limit(
|
||||||
}
|
recentEventLimit,
|
||||||
|
).Find(&events)
|
||||||
events, err = loadRecentEvents(
|
|
||||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, "failed to load recent events", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +450,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Targets": delivery.NewTargetViews(targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ func (s eventSource) event() *database.Event {
|
|||||||
Method: s.Method,
|
Method: s.Method,
|
||||||
Headers: s.HeadersJSON,
|
Headers: s.HeadersJSON,
|
||||||
Body: string(s.Body),
|
Body: string(s.Body),
|
||||||
BodyBytes: int64(len(s.Body)),
|
|
||||||
ContentType: s.ContentType,
|
ContentType: s.ContentType,
|
||||||
ResubmittedFromID: s.ResubmittedFromID,
|
ResubmittedFromID: s.ResubmittedFromID,
|
||||||
}
|
}
|
||||||
@@ -253,11 +252,12 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target
|
// createAndFanOut writes the event and one pending delivery per target,
|
||||||
// in a single transaction, then hands the tasks to the delivery
|
// and adds them to the webhook's running totals, in a single
|
||||||
// engine. It is the only path by which an event and its deliveries are
|
// transaction, then hands the tasks to the delivery engine. It is the
|
||||||
// created, so a resubmitted event is retried, SSRF-guarded and
|
// only path by which an event and its deliveries are created, so a
|
||||||
// circuit-broken exactly as a received one is.
|
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||||
|
// exactly as a received one is.
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
@@ -297,6 +297,16 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = database.AddTotals(tx, database.Totals{
|
||||||
|
Events: 1,
|
||||||
|
Deliveries: int64(len(tasks)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The spans of the two recent windows the statistics pane reports on:
|
||||||
|
// the last 10 minutes and the last 24 hours.
|
||||||
|
const (
|
||||||
|
shortWindow = 10 * time.Minute
|
||||||
|
longWindow = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// percent turns a fraction into a percentage.
|
||||||
|
const percent = 100
|
||||||
|
|
||||||
|
// WebhookStats holds the figures in the statistics pane at the top of
|
||||||
|
// the webhook page.
|
||||||
|
type WebhookStats struct {
|
||||||
|
Entrypoints int
|
||||||
|
ActiveEntrypoints int
|
||||||
|
Targets int
|
||||||
|
ActiveTargets int
|
||||||
|
|
||||||
|
// Totals holds the lifetime counts of events, deliveries and
|
||||||
|
// failures, and how many of each retention has removed.
|
||||||
|
Totals database.Totals
|
||||||
|
|
||||||
|
// InProgress counts the deliveries still pending or retrying.
|
||||||
|
InProgress int64
|
||||||
|
|
||||||
|
// LastEventAt is when the newest stored event arrived, or nil when
|
||||||
|
// none is stored.
|
||||||
|
LastEventAt *time.Time
|
||||||
|
|
||||||
|
Last10Minutes RecentWindow
|
||||||
|
Last24Hours RecentWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentWindow holds what happened in one recent window: the events
|
||||||
|
// received in it, and the deliveries that became delivered or failed in
|
||||||
|
// it.
|
||||||
|
type RecentWindow struct {
|
||||||
|
Events int64
|
||||||
|
Delivered int64
|
||||||
|
Failed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailurePercent is the share of the deliveries finished in the window
|
||||||
|
// that failed, or a dash when none finished. Deliveries still pending
|
||||||
|
// or retrying are not counted either way.
|
||||||
|
func (w RecentWindow) FailurePercent() string {
|
||||||
|
finished := w.Delivered + w.Failed
|
||||||
|
if finished == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||||
|
// webhook's entrypoints and targets, as the page has already loaded
|
||||||
|
// them, and from its event database. It returns nil, and logs why, when
|
||||||
|
// the event database cannot be read.
|
||||||
|
func (h *Handlers) loadWebhookStats(
|
||||||
|
webhookID string,
|
||||||
|
entrypoints []database.Entrypoint,
|
||||||
|
targets []database.Target,
|
||||||
|
) *WebhookStats {
|
||||||
|
stats := &WebhookStats{
|
||||||
|
Entrypoints: len(entrypoints),
|
||||||
|
Targets: len(targets),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range entrypoints {
|
||||||
|
if entrypoints[i].Active {
|
||||||
|
stats.ActiveEntrypoints++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
if targets[i].Active {
|
||||||
|
stats.ActiveTargets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an event database that does not exist would create it,
|
||||||
|
// and it would hold nothing to count.
|
||||||
|
if !h.dbMgr.DBExists(webhookID) {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||||
|
if err == nil {
|
||||||
|
err = readEventStats(webhookDB, time.Now(), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read webhook statistics",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEventStats fills in the figures that come from the webhook's
|
||||||
|
// event database. None of them reads every stored row: the totals are
|
||||||
|
// one row, and every other figure is read from an index, over only the
|
||||||
|
// rows it counts.
|
||||||
|
func readEventStats(
|
||||||
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||||
|
) error {
|
||||||
|
err := db.Take(&stats.Totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading running totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).
|
||||||
|
Count(&stats.InProgress).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting deliveries in progress: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newest []time.Time
|
||||||
|
|
||||||
|
err = db.Model(&database.Event{}).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(1).
|
||||||
|
Pluck("created_at", &newest).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading newest event time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newest) > 0 {
|
||||||
|
stats.LastEventAt = &newest[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last10Minutes, err = readRecentWindow(
|
||||||
|
db, now.Add(-shortWindow),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last24Hours, err = readRecentWindow(
|
||||||
|
db, now.Add(-longWindow),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRecentWindow counts the events received, and the deliveries that
|
||||||
|
// became delivered or failed, since the given time.
|
||||||
|
func readRecentWindow(
|
||||||
|
db *gorm.DB, since time.Time,
|
||||||
|
) (RecentWindow, error) {
|
||||||
|
var w RecentWindow
|
||||||
|
|
||||||
|
err := db.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Count(&w.Events).Error
|
||||||
|
if err != nil {
|
||||||
|
return w, fmt.Errorf("counting recent events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Delivered, err = countFinishedSince(
|
||||||
|
db, database.DeliveryStatusDelivered, since,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Failed, err = countFinishedSince(
|
||||||
|
db, database.DeliveryStatusFailed, since,
|
||||||
|
)
|
||||||
|
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFinishedSince counts the deliveries that reached the given
|
||||||
|
// final status since the given time.
|
||||||
|
func countFinishedSince(
|
||||||
|
db *gorm.DB, status database.DeliveryStatus, since time.Time,
|
||||||
|
) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
|
||||||
|
err := db.Model(&database.Delivery{}).
|
||||||
|
Where("status = ? AND finished_at >= ?", status, since).
|
||||||
|
Count(&n).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"counting deliveries %s recently: %w", status, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
||||||
|
func statsEntrypoint(
|
||||||
|
t *testing.T, db *database.Database, webhookID string, active bool,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
||||||
|
|
||||||
|
return ep.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsDelivery returns the id of an event's delivery to a target.
|
||||||
|
func statsDelivery(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var d database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Where(
|
||||||
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
||||||
|
).First(&d).Error)
|
||||||
|
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsFinish settles a delivery as the delivery engine does: its
|
||||||
|
// final status and the time it finished, and for a failure one more on
|
||||||
|
// the webhook's failure total, in one transaction.
|
||||||
|
func statsFinish(
|
||||||
|
t *testing.T,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", deliveryID).
|
||||||
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
||||||
|
Error
|
||||||
|
if err != nil || status != database.DeliveryStatusFailed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTotals(tx, database.Totals{Failures: 1})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsAge moves an event's arrival back to the given time.
|
||||||
|
func statsAge(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Model(&database.Event{}).
|
||||||
|
Where("id = ?", eventID).
|
||||||
|
Update("created_at", at).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStatsHistory builds the webhook the statistics test checks: one
|
||||||
|
// day of retention, two entrypoints (one inactive) and three targets
|
||||||
|
// (one inactive). Three events arrive through the receiver, and so
|
||||||
|
// each has a delivery to the two active targets. The oldest event is
|
||||||
|
// past retention, the middle one six hours old, the newest just in.
|
||||||
|
// Their deliveries are settled as the delivery engine would, and a
|
||||||
|
// replay adds a pending delivery to the oldest event. It returns the
|
||||||
|
// webhook, its event database and the newest event.
|
||||||
|
func seedStatsHistory(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
) (*database.Webhook, *gorm.DB, database.Event) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "stats", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
statsEntrypoint(t, db, wh.ID, false)
|
||||||
|
|
||||||
|
first := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
require.NoError(t, db.DB().Model(inactive).
|
||||||
|
Update("active", false).Error)
|
||||||
|
|
||||||
|
router := receiverRouter(h)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, 3)
|
||||||
|
|
||||||
|
oldest, middle, newest := events[0], events[1], events[2]
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, oldest.ID, now.Add(-50*time.Hour))
|
||||||
|
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
||||||
|
|
||||||
|
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
||||||
|
statsFinish(t, webhookDB, oldestFailure,
|
||||||
|
database.DeliveryStatusFailed, now.Add(-49*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther,
|
||||||
|
postReplay(t, h, sess, wh.ID, oldestFailure).Code)
|
||||||
|
|
||||||
|
return wh, webhookDB, newest
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPrune runs the real retention reaper until it has removed one
|
||||||
|
// event from the webhook's database, then stops it.
|
||||||
|
func statsPrune(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
log *logger.Logger,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
||||||
|
Config: &config.Config{
|
||||||
|
RetentionSweepInterval: 10 * time.Millisecond,
|
||||||
|
},
|
||||||
|
Database: db,
|
||||||
|
DBManager: dbMgr,
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var totals database.Totals
|
||||||
|
|
||||||
|
err := webhookDB.Take(&totals).Error
|
||||||
|
|
||||||
|
return err == nil && totals.EventsRemoved == 1
|
||||||
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
lc.RequireStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStatsTotals checks the lifetime events, deliveries and
|
||||||
|
// failures, and those within retention.
|
||||||
|
func assertStatsTotals(
|
||||||
|
t *testing.T, totals database.Totals, lifetime, within [3]int64,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
gotLifetime := [3]int64{
|
||||||
|
totals.Events, totals.Deliveries, totals.Failures,
|
||||||
|
}
|
||||||
|
gotWithin := [3]int64{
|
||||||
|
totals.EventsWithinRetention(),
|
||||||
|
totals.DeliveriesWithinRetention(),
|
||||||
|
totals.FailuresWithinRetention(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, lifetime, gotLifetime,
|
||||||
|
"lifetime events, deliveries, failures")
|
||||||
|
assert.Equal(t, within, gotWithin,
|
||||||
|
"events, deliveries, failures within retention")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
||||||
|
// the statistics pane shows for the history seedStatsHistory builds,
|
||||||
|
// before and after the real retention reaper removes the oldest event.
|
||||||
|
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh, webhookDB, newest := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||||
|
|
||||||
|
stats := h.WebhookStatsForTest(wh.ID)
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, stats.Entrypoints)
|
||||||
|
assert.Equal(t, 1, stats.ActiveEntrypoints)
|
||||||
|
assert.Equal(t, 3, stats.Targets)
|
||||||
|
assert.Equal(t, 2, stats.ActiveTargets)
|
||||||
|
assertStatsTotals(t, stats.Totals, [3]int64{3, 7, 3}, [3]int64{3, 7, 3})
|
||||||
|
assert.Equal(t, int64(2), stats.InProgress)
|
||||||
|
require.NotNil(t, stats.LastEventAt)
|
||||||
|
assert.True(t, newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||||
|
assert.Equal(t, handlers.RecentWindow{
|
||||||
|
Events: 1, Delivered: 1, Failed: 1,
|
||||||
|
}, stats.Last10Minutes)
|
||||||
|
assert.Equal(t, handlers.RecentWindow{
|
||||||
|
Events: 2, Delivered: 1, Failed: 2,
|
||||||
|
}, stats.Last24Hours)
|
||||||
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
||||||
|
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
||||||
|
|
||||||
|
// Retention removes the oldest event with its three deliveries,
|
||||||
|
// one of them failed and one the pending replay.
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
|
||||||
|
after := h.WebhookStatsForTest(wh.ID)
|
||||||
|
require.NotNil(t, after)
|
||||||
|
|
||||||
|
assertStatsTotals(t, after.Totals, [3]int64{3, 7, 3}, [3]int64{2, 4, 2})
|
||||||
|
assert.Equal(t, int64(1), after.InProgress)
|
||||||
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
||||||
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
||||||
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
assert.Contains(t, body, "Statistics")
|
||||||
|
assert.Contains(t, body, "Within retention")
|
||||||
|
assert.Contains(t, body, "50.0%")
|
||||||
|
assert.Contains(t, body, "66.7%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
||||||
|
// database has never been opened: every count is zero, the
|
||||||
|
// percentages are a dash, and showing the page does not create the
|
||||||
|
// database.
|
||||||
|
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||||
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
assert.Contains(t, body, "Statistics")
|
||||||
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentWindow_FailurePercent pins the percentage: failed
|
||||||
|
// deliveries out of all that finished in the window.
|
||||||
|
func TestRecentWindow_FailurePercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
window handlers.RecentWindow
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{handlers.RecentWindow{}, "—"},
|
||||||
|
{handlers.RecentWindow{Events: 4}, "—"},
|
||||||
|
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
||||||
|
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
||||||
|
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
|||||||
//
|
//
|
||||||
// A limiter that spends budget on arrival cannot protect a
|
// A limiter that spends budget on arrival cannot protect a
|
||||||
// single-admin product: behind the reverse proxy the deployment
|
// single-admin product: behind the reverse proxy the deployment
|
||||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||||
// keeps the one bucket full and the operator's own correct password
|
// bucket full and the operator's own correct password is answered 429
|
||||||
// is answered 429 forever. There is no second administrative path.
|
// forever. There is no second administrative path.
|
||||||
//
|
//
|
||||||
// So budget is spent only by a FAILED verification. A correct
|
// So budget is spent only by a FAILED verification. A correct
|
||||||
// password is never throttled, whatever the counters say, which is
|
// password is never throttled, whatever the counters say, which is
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
|
|||||||
return prefix.String()
|
return prefix.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTrustedProxy reports whether addr belongs to a network in
|
// isTrustedProxy reports whether addr belongs to a network the
|
||||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||||
|
// so by default nothing is trusted.
|
||||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||||
for _, prefix := range m.params.Config.TrustedProxies {
|
for _, prefix := range m.params.Config.TrustedProxies {
|
||||||
if prefix.Contains(addr) {
|
if prefix.Contains(addr) {
|
||||||
|
|||||||
@@ -384,8 +384,8 @@ const (
|
|||||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||||
// tests configure, and trustedPeer an address inside it. A
|
// tests configure, and trustedPeer an address inside it. A
|
||||||
// production deployment is required to run behind a reverse
|
// production deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||||
// a set value, so this is the shape the bucketing has to hold in.
|
// bucketing has to hold in.
|
||||||
trustedProxyCIDR = "10.0.0.0/8"
|
trustedProxyCIDR = "10.0.0.0/8"
|
||||||
trustedPeer = "10.0.0.1:44444"
|
trustedPeer = "10.0.0.1:44444"
|
||||||
)
|
)
|
||||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||||
// this gating exists for: from a peer that is not a trusted
|
// this gating exists for: with no trusted proxies configured (the
|
||||||
// proxy, a client that rotates a forwarded header on every
|
// default), a client that rotates a forwarded header on every
|
||||||
// request must stay in one bucket. If forwarded headers were
|
// request must stay in one bucket. If forwarded headers were
|
||||||
// trusted unconditionally, each spoofed value would mint a fresh
|
// trusted unconditionally, each spoofed value would mint a fresh
|
||||||
// bucket and the limit would stop no one.
|
// bucket and the limit would stop no one.
|
||||||
@@ -1097,9 +1097,8 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
|||||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||||
// names forwarded as its client in X-Forwarded-For. That is the
|
// names forwarded as its client in X-Forwarded-For. That is the
|
||||||
// production path: a deployment is required to run behind a reverse
|
// production path: a deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||||
// set value, so the forwarded address, not the peer, is what the
|
// peer, is what the limiters bucket on there.
|
||||||
// limiters bucket on there.
|
|
||||||
func forwardedKeyFor(
|
func forwardedKeyFor(
|
||||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||||
) string {
|
) string {
|
||||||
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||||
// bucketKey is the identity function, so replacing the call with
|
// bucketKey is the identity function, so replacing the call with
|
||||||
// peer.String() leaves the whole suite green. Only addresses inside
|
// peer.String() leaves the whole suite green. Only operator-listed
|
||||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
// addresses reach this line and the fallback is fail-closed, so this
|
||||||
// this pins behaviour rather than fixing a defect.
|
// pins behaviour rather than fixing a defect.
|
||||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
|||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// One application per test function, not per case: every start that
|
||||||
// finds no account seeds one with an Argon2id hash, and this package's
|
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||||
// budget is not the place to spend that repeatedly.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
//go:build browser
|
|
||||||
|
|
||||||
// This test needs a headless browser, so it is built only with the
|
|
||||||
// browser build tag: `make test` leaves it out, and `make test-browser`
|
|
||||||
// runs it in the browser image that Dockerfile.browser pins.
|
|
||||||
|
|
||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/log"
|
|
||||||
"github.com/chromedp/cdproto/network"
|
|
||||||
"github.com/chromedp/cdproto/runtime"
|
|
||||||
"github.com/chromedp/chromedp"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// browserTimeout bounds everything one test does in the browser.
|
|
||||||
browserTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// settleTimeout bounds the wait for an element to show or hide.
|
|
||||||
settleTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// The window size of a phone, narrow enough that the pages show
|
|
||||||
// the mobile menu button instead of the navigation links.
|
|
||||||
phoneWidth = 390
|
|
||||||
phoneHeight = 844
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
|
||||||
// event log in a headless browser, served by the real router and so
|
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
|
||||||
// Alpine.js directives work.
|
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, problems := startBrowser(t)
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
srv := httptest.NewServer(env.router)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
|
||||||
webhook := env.seedWebhook(t, userID)
|
|
||||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
|
||||||
target := env.seedTarget(t, webhook.ID)
|
|
||||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
||||||
&database.DeliveryResult{
|
|
||||||
DeliveryID: dlv.ID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
StatusCode: http.StatusBadGateway,
|
|
||||||
},
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
||||||
))
|
|
||||||
|
|
||||||
page := srv.URL + "/source/" + webhook.ID
|
|
||||||
|
|
||||||
checkAddForms(ctx, t, page)
|
|
||||||
checkTargetType(ctx, t)
|
|
||||||
checkEventLog(ctx, t, page+"/logs", event.ID, target.Name)
|
|
||||||
checkMobileMenu(ctx, t, page)
|
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
|
||||||
}
|
|
||||||
|
|
||||||
// startBrowser starts a headless browser for one test. It returns the
|
|
||||||
// context that drives it, and a function listing what the browser
|
|
||||||
// reported going wrong on its pages: console warnings and errors,
|
|
||||||
// which is how Alpine.js reports an expression it cannot run; uncaught
|
|
||||||
// exceptions; and every entry in the browser's own security log, which
|
|
||||||
// is where it reports each script, style, image or request the
|
|
||||||
// Content-Security-Policy refused.
|
|
||||||
//
|
|
||||||
// The browser library finds the browser on PATH. Without one the first
|
|
||||||
// chromedp.Run fails, and with it the test.
|
|
||||||
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
|
||||||
t.Context(),
|
|
||||||
append(
|
|
||||||
chromedp.DefaultExecAllocatorOptions[:],
|
|
||||||
// Dockerfile.browser runs the test as root, where the
|
|
||||||
// browser's sandbox cannot start.
|
|
||||||
chromedp.NoSandbox,
|
|
||||||
)...,
|
|
||||||
)
|
|
||||||
t.Cleanup(cancelAlloc)
|
|
||||||
|
|
||||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
|
||||||
t.Cleanup(cancelTimeout)
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
problems []string
|
|
||||||
)
|
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
|
||||||
var problem string
|
|
||||||
|
|
||||||
switch ev := ev.(type) {
|
|
||||||
case *runtime.EventConsoleAPICalled:
|
|
||||||
if ev.Type != runtime.APITypeWarning &&
|
|
||||||
ev.Type != runtime.APITypeError {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
args := make([]string, 0, len(ev.Args))
|
|
||||||
for _, arg := range ev.Args {
|
|
||||||
args = append(args, string(arg.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
problem = strings.Join(args, " ")
|
|
||||||
case *runtime.EventExceptionThrown:
|
|
||||||
problem = ev.ExceptionDetails.Error()
|
|
||||||
case *log.EventEntryAdded:
|
|
||||||
if ev.Entry.Source != log.SourceSecurity {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
problem = ev.Entry.Text
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
problems = append(problems, problem)
|
|
||||||
})
|
|
||||||
|
|
||||||
return ctx, func() []string {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
return slices.Clone(problems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCookies gives the browser the cookies for the server at base.
|
|
||||||
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
|
||||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
|
||||||
for _, c := range cookies {
|
|
||||||
err := network.SetCookie(c.Name, c.Value).
|
|
||||||
WithURL(base).
|
|
||||||
Do(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPage opens url and waits for Alpine.js to start, which it does
|
|
||||||
// by removing every x-cloak attribute. Until then x-cloak hides the
|
|
||||||
// elements Alpine would hide, so a check made earlier proves nothing.
|
|
||||||
func loadPage(url string) chromedp.Tasks {
|
|
||||||
return chromedp.Tasks{
|
|
||||||
chromedp.Navigate(url),
|
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// shown waits up to settleTimeout for the elements matching a CSS
|
|
||||||
// selector or an XPath expression to be rendered, and reports whether
|
|
||||||
// they were. The wait is needed because Alpine.js shows an element on
|
|
||||||
// the next animation frame, not at once.
|
|
||||||
func shown(ctx context.Context, selector string) bool {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return chromedp.Run(
|
|
||||||
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
|
||||||
) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hidden is shown's opposite: it waits for the elements to be hidden.
|
|
||||||
func hidden(ctx context.Context, selector string) bool {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return chromedp.Run(
|
|
||||||
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
|
||||||
) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// click clicks the element matching an XPath expression.
|
|
||||||
func click(ctx context.Context, t *testing.T, xpath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAddForms loads a webhook page and checks that each section's add
|
|
||||||
// form stays hidden until the Add button beside its heading is clicked.
|
|
||||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
sections := []struct{ heading, form string }{
|
|
||||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
|
||||||
{"Targets", `form[action$="/targets"]`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range sections {
|
|
||||||
assert.Truef(
|
|
||||||
t, hidden(ctx, s.form),
|
|
||||||
"%s: the add form shows before Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="`+s.heading+
|
|
||||||
`"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.Truef(
|
|
||||||
t, shown(ctx, s.form),
|
|
||||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTargetType chooses Slack in the open add target form and checks
|
|
||||||
// what the form would then submit: one url field, the Slack one, and
|
|
||||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
|
||||||
func checkTargetType(ctx context.Context, t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const (
|
|
||||||
chooseSlack = `(() => {
|
|
||||||
const type = document.querySelector('select[name="type"]');
|
|
||||||
type.value = "slack";
|
|
||||||
type.dispatchEvent(new Event("change"));
|
|
||||||
})()`
|
|
||||||
submitted = `[...new FormData(
|
|
||||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
||||||
)
|
|
||||||
|
|
||||||
var fields []string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.Evaluate(chooseSlack, nil),
|
|
||||||
chromedp.Evaluate(submitted, &fields),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
[]string{"csrf_token", "name", "type", "max_retries", "url"},
|
|
||||||
fields,
|
|
||||||
"with Slack chosen, the HTTP fields must not be submitted",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks that clicking an event's
|
|
||||||
// row expands it, that in there clicking its delivery shows the
|
|
||||||
// delivery's attempts and clicking again hides them, and that clicking
|
|
||||||
// the event's row again collapses it.
|
|
||||||
func checkEventLog(
|
|
||||||
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The event's row shows its ID, and its Resubmit form is in the part
|
|
||||||
// that expands. The delivery's row there shows the target's name.
|
|
||||||
eventRow := `//span[text()="` + eventID + `"]`
|
|
||||||
expanded := `form[action$="/resubmit"]`
|
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
|
||||||
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
|
||||||
|
|
||||||
click(ctx, t, deliveryRow)
|
|
||||||
assert.True(t, shown(ctx, attempt),
|
|
||||||
"clicking the delivery does not show its attempts")
|
|
||||||
|
|
||||||
click(ctx, t, deliveryRow)
|
|
||||||
assert.True(t, hidden(ctx, attempt),
|
|
||||||
"clicking the delivery again does not hide its attempts")
|
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
|
||||||
assert.True(t, hidden(ctx, expanded),
|
|
||||||
"clicking the event again does not collapse it")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
|
||||||
// the menu button opens and closes the mobile menu.
|
|
||||||
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The menu button is the only button directly in the navigation
|
|
||||||
// bar's top row. Profile is a link only the mobile menu has.
|
|
||||||
button := `//nav/div/button`
|
|
||||||
menu := `//nav//a[text()="Profile"]`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
|
||||||
loadPage(url),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
|
||||||
|
|
||||||
click(ctx, t, button)
|
|
||||||
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
|
||||||
|
|
||||||
click(ctx, t, button)
|
|
||||||
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
|
||||||
}
|
|
||||||
@@ -154,12 +154,11 @@ func (s *Server) setupPageRoutes() {
|
|||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||||
// does not cover it, every client shares one bucket, so a
|
// unset, every client shares one bucket, so a limiter spent
|
||||||
// limiter spent on arrival lets any stranger deny the operator
|
// on arrival lets any stranger deny the operator the only
|
||||||
// the only administrative path. The handler verifies
|
// administrative path. The handler verifies credentials first
|
||||||
// credentials first and charges only failures; see
|
// and charges only failures; see Handlers.authenticateUser.
|
||||||
// Handlers.authenticateUser.
|
|
||||||
r.Get("/login", s.h.HandleLoginPage())
|
r.Get("/login", s.h.HandleLoginPage())
|
||||||
r.Post("/login", s.h.HandleLoginSubmit())
|
r.Post("/login", s.h.HandleLoginSubmit())
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
|
|
||||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||||
// template loads on each page and fetches it through the real router.
|
// template loads on each page and fetches it through the real router.
|
||||||
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||||
// file is not in the tree: this is the check that the page still gets
|
// in the repo guarantees it is present: this is the check that the page
|
||||||
// the JavaScript it asks for.
|
// still gets the JavaScript it asks for.
|
||||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
|
||||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
|
|
||||||
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
|
||||||
# forbids eval. The extracted file is not committed. script/test, make
|
|
||||||
# build and make dev run this first.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
|
|
||||||
>static/js/alpine.min.js
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+8
-1
@@ -4,7 +4,9 @@
|
|||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||||
# only in docker, via script/lint and Dockerfile.lint.
|
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||||
|
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||||
|
# assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -67,6 +69,11 @@ main() {
|
|||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
# Third-party browser assets are not committed; fetch and verify them
|
||||||
|
# so a fresh clone can build and test.
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
"$ROOT/script/fetch-assets"
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
# extension to scripts-to-rule-them-all.
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
|
||||||
# Generic: usually needs no adaptation.
|
# Generic: usually needs no adaptation.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
|||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fetch-assets: download the third-party browser assets the web UI
|
||||||
|
# ships and install them under static/. Minified bundles are not committed
|
||||||
|
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||||
|
# fetches them here. Every download is verified against a hardcoded sha256
|
||||||
|
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||||
|
# already present with its pinned hash is left alone.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||||
|
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||||
|
# single source of truth: this script verifies against it, and
|
||||||
|
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||||
|
# it, so the hash cannot rot into a value nothing checks.
|
||||||
|
MANIFEST="static/vendor.sha256"
|
||||||
|
|
||||||
|
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||||
|
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||||
|
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||||
|
# a <script> tag.
|
||||||
|
ALPINE_VERSION="3.14.9"
|
||||||
|
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
# sha256 of alpinejs-3.14.9.tgz
|
||||||
|
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||||
|
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||||
|
ALPINE_DEST="js/alpine.min.js"
|
||||||
|
|
||||||
|
sha256_of() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 "$1" | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expected_sha256 <path-relative-to-static>
|
||||||
|
expected_sha256() {
|
||||||
|
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||||
|
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify <file> <expected-sha256> <what>
|
||||||
|
verify() {
|
||||||
|
actual="$(sha256_of "$1")"
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||||
|
up_to_date() {
|
||||||
|
[ -f "$ROOT/static/$1" ] || return 1
|
||||||
|
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_alpine() {
|
||||||
|
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||||
|
|
||||||
|
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||||
|
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||||
|
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||||
|
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||||
|
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-check every manifest entry against what is now on disk, so an entry
|
||||||
|
# no script installs fails loudly instead of passing silently.
|
||||||
|
verify_manifest() {
|
||||||
|
while read -r want path; do
|
||||||
|
case "$want" in '' | '#'*) continue ;; esac
|
||||||
|
if [ ! -f "$ROOT/static/$path" ]; then
|
||||||
|
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||||
|
done <"$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
fetch_alpine
|
||||||
|
verify_manifest
|
||||||
|
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+1
-7
@@ -22,19 +22,13 @@
|
|||||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||||
# 67s, that is the datum to revisit the org figure with.
|
# 67s, that is the datum to revisit the org figure with.
|
||||||
#
|
|
||||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
|
||||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
|
||||||
# -race every test binary and every link costs a few hundred MB, so the
|
|
||||||
# defaults (one per core) add up to several GB on a many-core host.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
go test -v -race -timeout 90s ./...
|
||||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/test-browser: run the browser test in internal/server. It runs in
|
|
||||||
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
|
|
||||||
# headless browser image, so the host needs no browser.
|
|
||||||
#
|
|
||||||
# --no-cache-filter=browser runs the test again even when nothing changed;
|
|
||||||
# it must name the stage in Dockerfile.browser that runs it.
|
|
||||||
# --output=type=cacheonly leaves no image behind to clean up.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
docker build \
|
|
||||||
-f Dockerfile.browser \
|
|
||||||
--no-cache-filter=browser \
|
|
||||||
--progress=plain \
|
|
||||||
--output=type=cacheonly \
|
|
||||||
.
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -57,62 +57,3 @@
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Alpine.js components.
|
|
||||||
//
|
|
||||||
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
|
||||||
// Alpine's CSP build, which cannot run expressions written in the
|
|
||||||
// markup: a directive in templates/ may only name a property or method,
|
|
||||||
// and each x-data names a component registered here. This script runs
|
|
||||||
// before Alpine, whose script tag is deferred, so this listener is in
|
|
||||||
// place when Alpine starts.
|
|
||||||
document.addEventListener("alpine:init", function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// Something a click shows and hides: the mobile menu, an add form,
|
|
||||||
// an event in the event log, a delivery's attempts.
|
|
||||||
window.Alpine.data("collapsible", function () {
|
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
toggle() {
|
|
||||||
this.open = !this.open;
|
|
||||||
},
|
|
||||||
get closed() {
|
|
||||||
return !this.open;
|
|
||||||
},
|
|
||||||
// Turns a downward caret up while open.
|
|
||||||
get caretClass() {
|
|
||||||
return { "rotate-180": this.open };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// The add target form. Only the chosen type's fields show, and the
|
|
||||||
// others are disabled so that the form does not submit them.
|
|
||||||
window.Alpine.data("targetForm", function () {
|
|
||||||
return {
|
|
||||||
targetType: "http",
|
|
||||||
chooseType(event) {
|
|
||||||
this.targetType = event.target.value;
|
|
||||||
},
|
|
||||||
get isHttp() {
|
|
||||||
return this.targetType === "http";
|
|
||||||
},
|
|
||||||
get isSlack() {
|
|
||||||
return this.targetType === "slack";
|
|
||||||
},
|
|
||||||
get isDatabase() {
|
|
||||||
return this.targetType === "database";
|
|
||||||
},
|
|
||||||
get notHttp() {
|
|
||||||
return !this.isHttp;
|
|
||||||
},
|
|
||||||
get notSlack() {
|
|
||||||
return !this.isSlack;
|
|
||||||
},
|
|
||||||
get notDatabase() {
|
|
||||||
return !this.isDatabase;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package static_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
const manifestPath = "vendor.sha256"
|
||||||
|
|
||||||
|
// fetchHint is appended to every failure here: the assets the manifest
|
||||||
|
// covers are fetched by the build, not committed, so a fresh clone that
|
||||||
|
// has not run script/fetch-assets fails this test and should be told why.
|
||||||
|
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||||
|
"the pinned third-party assets"
|
||||||
|
|
||||||
|
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||||
|
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||||
|
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||||
|
// this test verifies them again on what actually ships, so a build that
|
||||||
|
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||||
|
// unpinned third-party JavaScript.
|
||||||
|
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
entries := readManifest(t)
|
||||||
|
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||||
|
|
||||||
|
for path, want := range entries {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data, err := static.Static.ReadFile(path)
|
||||||
|
require.NoErrorf(
|
||||||
|
t, err,
|
||||||
|
"%s is listed in %s but is not embedded; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
require.Equalf(
|
||||||
|
t, want, got,
|
||||||
|
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||||
|
// format with paths relative to static/.
|
||||||
|
func readManifest(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.Open(manifestPath)
|
||||||
|
require.NoError(t, err, "opening %s", manifestPath)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, f.Close()) }()
|
||||||
|
|
||||||
|
entries := make(map[string]string)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
require.Lenf(
|
||||||
|
t, fields, 2,
|
||||||
|
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||||
|
manifestPath, line,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum, path := fields[0], fields[1]
|
||||||
|
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||||
|
entries[path] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{{define "navbar"}}
|
{{define "navbar"}}
|
||||||
<nav class="app-bar" x-data="collapsible">
|
<nav class="app-bar" x-data="{ open: false }">
|
||||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
||||||
|
<dd class="text-sm text-gray-900">Standard User</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
@@ -24,12 +24,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{template "webhook_stats" .}}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card" x-data="collapsible">
|
<div class="card">
|
||||||
<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">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||||
<button @click="toggle" class="btn-text text-sm">
|
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
@@ -85,10 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets -->
|
||||||
<div class="card" x-data="collapsible">
|
<div class="card">
|
||||||
<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">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button @click="toggle" class="btn-text text-sm">
|
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -97,42 +99,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="targetForm" class="space-y-3">
|
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
<select name="type" @change="chooseType" class="input text-sm w-32">
|
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isSlack">
|
<div x-show="targetType === 'slack'">
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isDatabase">
|
<div x-show="targetType === 'database'">
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||||
@@ -181,30 +183,18 @@
|
|||||||
<!-- Recent Events -->
|
<!-- Recent Events -->
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<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">
|
||||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||||
{{if .ResubmittedFromID}}
|
|
||||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
|
||||||
<span title="Body size">{{.Size}}</span>
|
|
||||||
{{if .ProcessingTime}}
|
|
||||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
|
||||||
{{end}}
|
|
||||||
{{if .Status}}
|
|
||||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
|
||||||
{{end}}
|
|
||||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4" x-data="collapsible">
|
<div class="p-4" x-data="{ open: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||||
<div class="mt-2 divide-y divide-gray-200">
|
<div class="mt-2 divide-y divide-gray-200">
|
||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<div class="py-2" x-data="collapsible">
|
<div class="py-2" x-data="{ attempts: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
||||||
{{if .AttemptsOmitted}}
|
{{if .AttemptsOmitted}}
|
||||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{{define "webhook_stats"}}
|
||||||
|
<!-- Statistics pane at the top of the webhook page. -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
||||||
|
</div>
|
||||||
|
{{with .Stats}}
|
||||||
|
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Entrypoints</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Targets</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Deliveries in progress</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Last event</span>
|
||||||
|
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Retention</span>
|
||||||
|
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="flex py-2 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="w-32 text-center">Lifetime</span>
|
||||||
|
<span class="w-32 text-center">Within retention</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Events</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.Events}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.EventsWithinRetention}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Deliveries</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.Deliveries}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.DeliveriesWithinRetention}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Failures</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.Failures}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Totals.FailuresWithinRetention}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex py-2 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="w-32 text-center">Last 10 minutes</span>
|
||||||
|
<span class="w-32 text-center">Last 24 hours</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Events</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.Events}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last24Hours.Events}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Failures</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.Failed}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last24Hours.Failed}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-2">
|
||||||
|
<span class="flex-1 text-gray-600">Failure percentage</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.FailurePercent}}</span>
|
||||||
|
<span class="w-32 text-center text-gray-900">{{.Last24Hours.FailurePercent}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user