Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ce961fe5 |
+4
-10
@@ -1,20 +1,14 @@
|
|||||||
# .git is sent so the build can derive the version it stamps into the binary
|
|
||||||
# (script/version). Its config, which can hold a remote URL carrying a
|
|
||||||
# credential and which `git describe` does not need, is left out of a
|
|
||||||
# directory context. A context sent as a tar is not filtered by this file, so
|
|
||||||
# it carries .git/config unless its sender leaves it out.
|
|
||||||
.git/config
|
|
||||||
|
|
||||||
# No tracked file may be listed here: git in the build would see it as
|
|
||||||
# deleted and mark the version -dirty.
|
|
||||||
#
|
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||||
# that keeps the check stages from replaying a cached pass. See the lint
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
.editorconfig
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
with:
|
with:
|
||||||
# The superseded-status step needs history to walk ancestors (it
|
# The fingerprint step below needs history to find the last commit
|
||||||
# aborts on a shallow clone).
|
# that touched the Docker build context, and the superseded-status
|
||||||
|
# step needs it to walk ancestors (it aborts on a shallow clone).
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Mark superseded run statuses
|
- name: Mark superseded run statuses
|
||||||
@@ -27,11 +28,16 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# Writes the hash of the commit being checked into the context, which
|
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||||
# invalidates the `COPY . .` layer of both check stages: a commit
|
# commit legitimately replays the whole image from cache and stays
|
||||||
# that was never linted, format-checked, tested and built cannot
|
# cheap. Every other commit writes a new fingerprint into the context,
|
||||||
# report success from cache.
|
# which invalidates the `COPY . .` layer of both check stages: a
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
# commit that was never linted, formatted-checked, tested and built
|
||||||
|
# cannot report success from cache.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
||||||
|
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make check)
|
- name: Build Docker image (runs make check)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
+9
-22
@@ -12,8 +12,8 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
||||||
# holds the hash of the commit being checked (see
|
# value changes with every commit that touches the build context (see
|
||||||
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
||||||
# below cannot report success by replaying a cached pass. Do not add it to
|
# below cannot report success by replaying a cached pass. Do not add it to
|
||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
@@ -38,13 +38,8 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes. git is what script/version derives the version with.
|
# suite executes.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# A build context sent as a tar archive keeps its files' owners, and git
|
|
||||||
# refuses to read a checkout owned by another user. Trust this one
|
|
||||||
# whoever owns it.
|
|
||||||
RUN git config --system --add safe.directory /build
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -60,22 +55,14 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary: the VERSION build arg when one is
|
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||||
# given, otherwise what script/version derives from the .git the build
|
# nothing in this stage can derive it: script/docker resolves it on the
|
||||||
# context carries, so any `docker build .` of a clone stamps its commit.
|
# host and passes it in. The default is what a bare `docker build .`
|
||||||
# With neither, as from a source tarball, it is "unknown".
|
# 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 step, so a changed version does not
|
||||||
# invalidate its cached layer.
|
# invalidate its cached layer.
|
||||||
ARG VERSION
|
ARG VERSION=unknown
|
||||||
|
|
||||||
# A context that carries .git must not stamp "unknown": that means git is
|
|
||||||
# missing here or could not read the checkout, and the image could not be
|
|
||||||
# traced back to its commit.
|
|
||||||
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
|
||||||
echo "version is unknown although the build context carries .git" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Version stamped into the binary. Derived from git by script/version;
|
# Version stamped into the binary. Derived from git by script/version;
|
||||||
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||||
# how the Dockerfile passes its build arg in.
|
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||||
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||||
# is required: a plain assignment loses to the command-line definition it
|
# is required: a plain assignment loses to the command-line definition it
|
||||||
|
|||||||
@@ -142,14 +142,9 @@ 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) |
|
||||||
|
|
||||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
|
||||||
every one of these with the value the running server loaded. It is
|
|
||||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
|
||||||
set or not set, never their values.
|
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -162,22 +157,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`), the unspecified address (`::`),
|
|
||||||
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
|
|
||||||
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); 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
|
||||||
@@ -216,16 +195,16 @@ Two things this setting cannot do:
|
|||||||
the list is always an allowlist; an empty list (the default) means
|
the list is always an allowlist; an empty list (the default) means
|
||||||
every private and reserved range stays refused. Note that
|
every private and reserved range stays refused. Note that
|
||||||
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
||||||
- **It cannot open link-local, the unspecified addresses, or a cloud
|
- **It cannot open link-local, or a cloud metadata endpoint at a
|
||||||
metadata endpoint at a non-public address that discloses credentials
|
non-public address that discloses credentials or user data.** An
|
||||||
or user data.** A metadata address is on the list below when it is not
|
address is on the list below when it is not a public address and both
|
||||||
a public address and both of these hold: the provider fixes it, so it
|
of these hold: the provider fixes it, so it cannot collide with
|
||||||
cannot collide with anything you run; and reaching it hands out
|
anything you run; and reaching it hands out credentials, user data or
|
||||||
credentials, user data or bootstrap material. Those stay blocked no
|
bootstrap material. Those stay blocked no matter what you list,
|
||||||
matter what you list, including when you list them outright or list a
|
including when you list them outright or list a supernet such as
|
||||||
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
|
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
|
||||||
Treat this as best effort rather than a guarantee — it is a
|
effort rather than a guarantee — it is a hand-maintained list and the
|
||||||
hand-maintained list and the caveat below the table applies:
|
caveat below the table applies:
|
||||||
|
|
||||||
| Blocked unconditionally | What it is |
|
| Blocked unconditionally | What it is |
|
||||||
| ----------------------- | ---------- |
|
| ----------------------- | ---------- |
|
||||||
@@ -239,25 +218,14 @@ Two things this setting cannot do:
|
|||||||
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
||||||
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
||||||
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
||||||
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
|
|
||||||
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
|
|
||||||
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
||||||
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
||||||
|
|
||||||
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
||||||
`169.254.0.0/16` entry. Reaching any of these but the two unspecified
|
`169.254.0.0/16` entry. Reaching any of these is credential or
|
||||||
addresses is credential or user-data theft rather than delivery to an
|
user-data theft rather than delivery to an internal service. Every
|
||||||
internal service. Every entry outside the two link-local blocks is a
|
entry outside the two link-local blocks is a single address, so
|
||||||
single address, so blocking it costs you nothing else on the network
|
blocking it costs you nothing else on the network around it.
|
||||||
around it.
|
|
||||||
|
|
||||||
The unspecified addresses `0.0.0.0` and `::` hand out nothing
|
|
||||||
themselves, but no host can have either, and on Linux a connection to
|
|
||||||
one reaches this host's own loopback. They are listed so that an
|
|
||||||
allowlist reaches loopback only through an entry that covers a loopback
|
|
||||||
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
|
|
||||||
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
|
|
||||||
open loopback.
|
|
||||||
|
|
||||||
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
||||||
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
||||||
@@ -406,37 +374,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
|
||||||
@@ -452,10 +424,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
|
||||||
|
|
||||||
@@ -754,15 +736,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`.
|
||||||
@@ -825,16 +802,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
|
||||||
@@ -1092,7 +1065,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`, `EventTotals`, `TargetTotals`
|
`DeliveryResult`
|
||||||
- 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
|
||||||
@@ -1150,29 +1123,13 @@ build itself.
|
|||||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||||
| No git metadata | `unknown` |
|
| No git metadata | `unknown` |
|
||||||
|
|
||||||
The image derives it the same way, from the `.git` that the build
|
`unknown` is what a source tarball or a `docker build .` with no
|
||||||
context carries, so any `docker build .` of a clone, with no build
|
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
||||||
arguments, stamps the commit it was built from; a shallow clone of one
|
the build context carries no git metadata and the image cannot derive
|
||||||
branch has no tags and stamps the short SHA. `.dockerignore` must
|
the version itself: `script/docker` (and so `make docker`) resolves it
|
||||||
therefore leave out neither `.git` nor any tracked file, which git in
|
on the host and passes it in as the `VERSION` build arg. A build that
|
||||||
the build would see as deleted, marking the version `-dirty`. It does
|
reports `unknown` is a build nobody told what it was; it is not a
|
||||||
leave `.git/config`, which can hold a remote URL carrying a credential
|
failure, but it cannot be traced back to a commit.
|
||||||
and which `git describe` does not need, out of a directory context. A
|
|
||||||
context sent as a tar is not filtered by `.dockerignore`, so it carries
|
|
||||||
`.git/config` unless its sender leaves it out; for upaas, that is
|
|
||||||
https://git.eeqj.de/sneak/upaas/issues/274. git in the build
|
|
||||||
reads the checkout whoever owns its files, since a context sent as a tar
|
|
||||||
archive keeps the sender's owners and git otherwise refuses a checkout
|
|
||||||
owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`)
|
|
||||||
takes precedence; `script/docker` (and so `make docker`) passes the one
|
|
||||||
`script/version` resolves on the host. The image build fails if its
|
|
||||||
context carries `.git` and the version still comes out `unknown`, which
|
|
||||||
means git is missing from the build or could not read the checkout.
|
|
||||||
|
|
||||||
`unknown` is what a source tarball, or a `docker build` with no `.git`
|
|
||||||
in its context and no `VERSION` build arg, reports. A build that reports
|
|
||||||
`unknown` is a build nobody told what it was; it is not a failure, but
|
|
||||||
it cannot be traced back to a commit.
|
|
||||||
|
|
||||||
`make version` prints what the current checkout would stamp, and
|
`make version` prints what the current checkout would stamp, and
|
||||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||||
@@ -1403,11 +1360,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
|
||||||
@@ -1425,7 +1381,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has eleven entities organized into two tiers: the
|
webhooker's data model has nine 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).
|
||||||
|
|
||||||
@@ -1454,13 +1410,6 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ (one row: running counts of events) │
|
|
||||||
│ │ EventTotals │ │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
│ ┌──────────────┐ (one row per target: running counts │
|
|
||||||
│ │ TargetTotals │ of its deliveries) │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1693,7 +1642,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
|
||||||
@@ -1714,7 +1662,6 @@ 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.
|
||||||
@@ -1782,70 +1729,33 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
#### EventTotals and TargetTotals
|
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
|
||||||
top of the webhook page. `EventTotals` is one row:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| ---------------- | --------- | ----------- |
|
|
||||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
|
||||||
| `events_removed` | integer | Events retention has deleted |
|
|
||||||
| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is |
|
|
||||||
|
|
||||||
`TargetTotals` is one row per target, created by the first delivery to it:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------------- | ------- | ----------- |
|
|
||||||
| `target_id` | UUID | The target (primary key) |
|
|
||||||
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
|
||||||
| `delivered` | integer | Of those, how many became `delivered` |
|
|
||||||
| `failed` | integer | Of those, how many became `failed` |
|
|
||||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
|
||||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
|
||||||
|
|
||||||
Each count changes in the transaction that writes or deletes the rows it
|
|
||||||
counts. The pane's lifetime events are `events`, and its lifetime
|
|
||||||
deliveries and failures are `deliveries` and `failed` summed over the
|
|
||||||
targets; each figure within retention is the same less what retention
|
|
||||||
removed, so neither needs the rows themselves. Its last event is
|
|
||||||
`last_event_at`, written in the transaction that stores the event, so it
|
|
||||||
still shows once retention has removed every event. Its last-10-minutes and
|
|
||||||
last-24-hours figures are counted from the `events` and `deliveries`
|
|
||||||
indexes over just that window, the deliveries in one query grouped by
|
|
||||||
target. 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
|
||||||
tags, so `AutoMigrate` creates them on a fresh database:
|
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | 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 each target's deliveries by status and when they finished |
|
| `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` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts 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` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
no statistics on these tables, and without them it rates the `deleted_at`
|
||||||
a column matched against several values or compared with a range. So every
|
index, which every live row matches, above an index on a column matched
|
||||||
index but the last also covers `deleted_at`. It comes second, so that
|
against several values or compared with `<`. So every index but the last also
|
||||||
retention can use the index without it, except in `events`, where the
|
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
||||||
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
index without it, except in `events`, where `created_at` is compared with `<`
|
||||||
range only on the last column it uses.
|
and SQLite narrows by a `<` only on the last column it uses.
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
Every entity except `Setting` includes these fields from `BaseModel`.
|
||||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
||||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
soft delete:
|
||||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
|
||||||
and by `target_id`:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1887,8 +1797,6 @@ 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
|
||||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
|
||||||
the deliveries per target, 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
|
||||||
@@ -2625,44 +2533,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:
|
||||||
@@ -2759,10 +2670,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
|
||||||
@@ -2783,21 +2692,16 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------- | ----------- |
|
| ------ | --------------- | ----------- |
|
||||||
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` |
|
| `GET` | `/pages/login` | Login page (not rate limited) |
|
||||||
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/pages/login` | Login form submission. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||||
| `POST` | `/pages/logout` | Logout (destroys session) |
|
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||||
|
|
||||||
#### Authenticated Endpoints
|
#### Authenticated Endpoints
|
||||||
|
|
||||||
A logged-out `GET` of any of these is redirected to `/pages/login` with
|
|
||||||
its path and query as `next` when they fit in 2048 bytes, so logging in
|
|
||||||
returns to the page that was asked for.
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
|
||||||
| `GET` | `/hooks` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/hooks/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/hooks/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
@@ -2873,7 +2777,6 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -2911,7 +2814,6 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
│ │ ├── settings.go # Read-only Settings page handler
|
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -2970,15 +2872,13 @@ Components are wired via Uber fx in this order:
|
|||||||
7. `healthcheck.New` — Health check service
|
7. `healthcheck.New` — Health check service
|
||||||
8. `session.New` — Cookie-based session manager (key from database)
|
8. `session.New` — Cookie-based session manager (key from database)
|
||||||
9. `handlers.New` — HTTP handlers
|
9. `handlers.New` — HTTP handlers
|
||||||
10. `metrics.NewRegistry` — The registry `/metrics` serves
|
10. `middleware.New` — HTTP middleware
|
||||||
11. `metrics.New` — The delivery collectors, registered on that registry
|
11. `delivery.New` — Event-driven delivery engine
|
||||||
12. `middleware.New` — HTTP middleware
|
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
||||||
13. `delivery.New` — Event-driven delivery engine
|
13. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
||||||
14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
14. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
|
||||||
15. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
|
||||||
16. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
|
|
||||||
deleting a webhook releases its archive writer
|
deleting a webhook releases its archive writer
|
||||||
17. `server.New` — HTTP server and router
|
15. `server.New` — HTTP server and router
|
||||||
|
|
||||||
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
|
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
|
||||||
*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which
|
*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which
|
||||||
@@ -3021,14 +2921,8 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
|
|
||||||
error page in the normal layout; the global one keeps the plain-text `500` for
|
|
||||||
every other route.
|
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
|
||||||
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
|
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -3047,7 +2941,7 @@ declared length. A chunked request, or
|
|||||||
one that lies about its length, is hard-capped by
|
one that lies about its length, is hard-capped by
|
||||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||||
|
|
||||||
Those same five route groups then apply **CSRF** and **NoCache**
|
Those same four route groups then apply **CSRF** and **NoCache**
|
||||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
@@ -3093,12 +2987,12 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
POSTs) and `/api` (stateless API). The middleware detects TLS
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
session cookie uses — to set appropriate cookie security flags and
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
Origin/Referer validation mode
|
mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission, and no shared secret or signature check will
|
||||||
@@ -3112,8 +3006,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
route through a single decision function, so they cannot disagree
|
route through a single decision function, so they cannot disagree
|
||||||
about a destination. An operator can permit specific blocks with
|
about a destination. An operator can permit specific blocks with
|
||||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
||||||
guard cannot be switched off, and link-local, the unspecified
|
guard cannot be switched off, and link-local plus a
|
||||||
addresses `0.0.0.0` and `::`, and a
|
|
||||||
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
||||||
metadata endpoints — several of which are ULAs outside link-local —
|
metadata endpoints — several of which are ULAs outside link-local —
|
||||||
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
||||||
@@ -3136,9 +3029,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)
|
||||||
@@ -3148,8 +3042,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
before the app starts; the image's health check; and `docker exec`,
|
before the app starts; the image's health check; and `docker exec`,
|
||||||
unless given `--user`
|
unless given `--user`
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
all of them but `Setting` (data preserved for audit)
|
||||||
preserved for audit)
|
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
@@ -3271,9 +3164,8 @@ version is fixed independently of the compiler's:
|
|||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
stamps the version. The version is the `VERSION` build arg if one is
|
stamps the version. The version arrives as the `VERSION` build arg,
|
||||||
given, otherwise derived from the `.git` in the context, and the
|
since the context has no `.git` (see
|
||||||
stage fails if a context with `.git` would stamp `unknown` (see
|
|
||||||
[Version stamping](#version-stamping)).
|
[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`
|
||||||
@@ -3305,13 +3197,19 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
|
|||||||
test stages replayed rather than executed, which would make a green
|
test stages replayed rather than executed, which would make a green
|
||||||
check meaningless. The `check` workflow therefore writes
|
check meaningless. The `check` workflow therefore writes
|
||||||
`.ci-fingerprint` into the build context before building. Its value is
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
the hash of the commit being checked, so every commit, docs-only ones
|
the hash of the last commit that touched the build context, so:
|
||||||
and a squash merge whose tree matches an already-built branch included,
|
|
||||||
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
|
||||||
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
|
||||||
and `make build`. A run that reports success ran them.
|
|
||||||
|
|
||||||
The module download layer sits above `COPY . .` and stays cached.
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
|
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
||||||
|
run that reports success ran them.
|
||||||
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
|
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||||
|
anyway — so the image replays from cache and costs seconds.
|
||||||
|
|
||||||
|
The module download layer sits above `COPY . .` and stays cached either
|
||||||
|
way.
|
||||||
|
|
||||||
A separate workflow step, run before the fingerprint is written, covers
|
A separate workflow step, run before the fingerprint is written, covers
|
||||||
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ duplicate. That is deliberate — the alternative is a silent lost
|
|||||||
delivery — and the README says so under Rationale. It is not a defect
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
to re-file.
|
to re-file.
|
||||||
|
|
||||||
|
One caveat on reading a green check: a docs-only commit deliberately
|
||||||
|
replays from the layer cache
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||||
|
such a commit evidences a replay rather than an executed run. A code
|
||||||
|
commit invalidates the `COPY` layer and genuinely executes.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
@@ -381,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)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
@@ -178,10 +177,6 @@ func newApp() *fx.App {
|
|||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
// The registry /metrics serves, and the delivery
|
|
||||||
// collectors registered on it.
|
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
// The one SSRF guard both target-creation validation
|
// The one SSRF guard both target-creation validation
|
||||||
// and the delivery dialer consult, so they cannot
|
// and the delivery dialer consult, so they cannot
|
||||||
|
|||||||
@@ -4,7 +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/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
|
||||||
@@ -30,6 +29,7 @@ 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // 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
|
||||||
|
|||||||
+66
-29
@@ -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
|
||||||
@@ -196,13 +190,12 @@ type Config struct {
|
|||||||
// otherwise refuse. The guard itself is always on: there is no
|
// otherwise refuse. The guard itself is always on: there is no
|
||||||
// setting that disables SSRF protection, and delivery's
|
// setting that disables SSRF protection, and delivery's
|
||||||
// alwaysBlockedNetworks stays blocked no matter what is listed
|
// alwaysBlockedNetworks stays blocked no matter what is listed
|
||||||
// here. That set is link-local, the unspecified addresses
|
// here. That set is link-local plus the cloud metadata
|
||||||
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
// endpoints outside it that disclose credentials or user data
|
||||||
// link-local that disclose credentials or user data at a
|
// at a provider-fixed, non-public address; it is not
|
||||||
// provider-fixed, non-public address; it is not exhaustive of
|
// exhaustive of every cloud's metadata address. See
|
||||||
// every cloud's metadata address. See
|
// alwaysBlockedNetworks for the authoritative list and the
|
||||||
// alwaysBlockedNetworks for the authoritative list and why
|
// criterion it is built from.
|
||||||
// each entry is on it.
|
|
||||||
AllowedEgressCIDRs []netip.Prefix
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
@@ -467,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
|
||||||
@@ -689,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
|
||||||
}
|
}
|
||||||
@@ -768,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.
|
||||||
@@ -813,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
-26
@@ -124,11 +124,6 @@ func testEnvironmentConfigSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned. The same holds for every fxtest.New
|
|
||||||
// below.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -277,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -370,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -411,7 +404,6 @@ func TestDefaultDataDir(t *testing.T) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -542,7 +534,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -560,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
|
||||||
@@ -573,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,
|
||||||
@@ -659,7 +642,6 @@ func testTrustedProxiesSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -773,7 +755,6 @@ func testAllowedEgressCIDRsSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -864,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
|
||||||
@@ -1017,7 +1093,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(globals.New, logger.New, config.New),
|
fx.Provide(globals.New, logger.New, config.New),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,11 +93,11 @@ 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 }
|
||||||
removed []database.TargetTotals
|
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||||
|
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
||||||
|
|
||||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
// stranded pending deliveries, and the queue depth count.
|
// stranded pending deliveries, and the queue depth count.
|
||||||
@@ -123,80 +123,25 @@ 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 (reapExpired, deleteEvents): one batch of expired
|
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||||
// events, then their attempts, deliveries and the events.
|
// afresh for each statement as it builds them.
|
||||||
var expired []string
|
expiredEventIDs := func() *gorm.DB {
|
||||||
|
return dry.Model(&database.Event{}).Select("id").
|
||||||
|
Where("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
|
||||||
"idx_events_created_at (created_at<?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||||
Select("id").Where("event_id IN ?", ids),
|
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)",
|
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
"event_id IN (?)", expiredEventIDs(),
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
).Delete(&database.Delivery{}),
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||||
database.DeliveryStatusFailed).
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
"created_at < ?", cutoff,
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
|
||||||
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
|
||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
|
||||||
// progress, each target's deliveries finished since a time, which must
|
|
||||||
// come from the index alone, and events received since a time.
|
|
||||||
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
|
|
||||||
byTarget []struct{ TargetID string }
|
|
||||||
)
|
|
||||||
|
|
||||||
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{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").Find(&byTarget),
|
|
||||||
"COVERING INDEX 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>?)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -29,10 +28,6 @@ func NewTestRetentionReaper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportReapBatchSize exposes how many expired events one retention
|
|
||||||
// transaction deletes.
|
|
||||||
const ExportReapBatchSize = reapBatchSize
|
|
||||||
|
|
||||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
r.sweep(ctx)
|
r.sweep(ctx)
|
||||||
@@ -84,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,10 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import "gorm.io/gorm"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -41,7 +37,7 @@ type Delivery struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||||
|
|
||||||
// DeletedAt repeats the BaseModel field only to be the second column
|
// DeletedAt repeats the BaseModel field only to be the second column
|
||||||
@@ -49,13 +45,6 @@ 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 and then TargetID end the
|
|
||||||
// status index, so the webhook page counts each target's deliveries
|
|
||||||
// that finished in a recent window by reading just 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
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The running totals in a webhook's event database keep the webhook
|
|
||||||
// page's lifetime figures right after retention has removed the rows
|
|
||||||
// they count, and let the page show them without counting every row.
|
|
||||||
// Each total changes in the transaction that writes or deletes the
|
|
||||||
// rows it counts.
|
|
||||||
|
|
||||||
// EventTotals is the single row counting a webhook's events: every
|
|
||||||
// event ever stored, how many of them retention has deleted, and when
|
|
||||||
// the newest arrived, which retention leaves as it is.
|
|
||||||
type EventTotals struct {
|
|
||||||
ID int64 `gorm:"primaryKey"`
|
|
||||||
|
|
||||||
Events int64 `gorm:"not null"`
|
|
||||||
EventsRemoved int64 `gorm:"not null"`
|
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil before the
|
|
||||||
// first.
|
|
||||||
LastEventAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddEventTotals updates.
|
|
||||||
func (EventTotals) TableName() string {
|
|
||||||
return "event_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetTotals is one row per target counting its deliveries: every
|
|
||||||
// delivery ever created, how many became delivered and how many
|
|
||||||
// failed, and how many deliveries and failed deliveries retention has
|
|
||||||
// deleted. The webhook's delivery figures are these rows summed.
|
|
||||||
type TargetTotals struct {
|
|
||||||
TargetID string `gorm:"type:uuid;primaryKey"`
|
|
||||||
|
|
||||||
Deliveries int64 `gorm:"not null"`
|
|
||||||
Delivered int64 `gorm:"not null"`
|
|
||||||
Failed int64 `gorm:"not null"`
|
|
||||||
|
|
||||||
DeliveriesRemoved int64 `gorm:"not null"`
|
|
||||||
FailedRemoved int64 `gorm:"not null"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddTargetTotals updates.
|
|
||||||
func (TargetTotals) TableName() string {
|
|
||||||
return "target_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
|
||||||
// and records add.LastEventAt as when the newest event arrived if it is
|
|
||||||
// set. Call it on the transaction that writes or deletes the events it
|
|
||||||
// counts.
|
|
||||||
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`UPDATE event_totals SET
|
|
||||||
events = events + ?,
|
|
||||||
events_removed = events_removed + ?,
|
|
||||||
last_event_at = coalesce(?, last_event_at)`,
|
|
||||||
add.Events, add.EventsRemoved, add.LastEventAt,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding to event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTargetTotals adds each count in add to the totals of the target
|
|
||||||
// add.TargetID names, creating its row the first time. Call it on the
|
|
||||||
// transaction that writes or deletes the deliveries it counts.
|
|
||||||
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
|
||||||
failed, deliveries_removed, failed_removed)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (target_id) DO UPDATE SET
|
|
||||||
deliveries = deliveries + excluded.deliveries,
|
|
||||||
delivered = delivered + excluded.delivered,
|
|
||||||
failed = failed + excluded.failed,
|
|
||||||
deliveries_removed =
|
|
||||||
deliveries_removed + excluded.deliveries_removed,
|
|
||||||
failed_removed = failed_removed + excluded.failed_removed`,
|
|
||||||
add.TargetID, add.Deliveries, add.Delivered,
|
|
||||||
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"adding to totals of target %s: %w", add.TargetID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,8 +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, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||||
// TargetTotals) 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
|
||||||
|
|||||||
+46
-111
@@ -18,19 +18,6 @@ import (
|
|||||||
// computation.
|
// computation.
|
||||||
const hoursPerDay = 24
|
const hoursPerDay = 24
|
||||||
|
|
||||||
// reapBatchSize is how many expired events one retention transaction
|
|
||||||
// deletes. A transaction holds the event database's write lock, which
|
|
||||||
// the receiver and the delivery workers wait for, so a large prune is
|
|
||||||
// split into transactions each short enough to finish well inside the
|
|
||||||
// busy timeout.
|
|
||||||
const reapBatchSize = 1000
|
|
||||||
|
|
||||||
// reapBatchPause is how long retention waits after one batch before
|
|
||||||
// starting the next. A writer waiting for the write lock checks for it
|
|
||||||
// again after at most 100 ms, so a longer pause lets it in between two
|
|
||||||
// batches instead of only after the whole prune.
|
|
||||||
const reapBatchPause = 200 * time.Millisecond
|
|
||||||
|
|
||||||
// RetentionReaperParams holds the fx dependencies for the
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
// RetentionReaper.
|
// RetentionReaper.
|
||||||
type RetentionReaperParams struct {
|
type RetentionReaperParams struct {
|
||||||
@@ -200,15 +187,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
|
r.reapWebhook(wh.ID, wh.RetentionDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapWebhook removes every expired event (and its dependents) from a
|
// reapWebhook removes every expired event (and its dependents) from a
|
||||||
// single webhook's database, or as many as it reaches before ctx is
|
// single webhook's database.
|
||||||
// cancelled.
|
|
||||||
func (r *RetentionReaper) reapWebhook(
|
func (r *RetentionReaper) reapWebhook(
|
||||||
ctx context.Context,
|
|
||||||
webhookID string,
|
webhookID string,
|
||||||
retentionDays int,
|
retentionDays int,
|
||||||
) {
|
) {
|
||||||
@@ -228,7 +213,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, err := reapExpired(ctx, db, cutoff)
|
deleted, err := reapExpired(db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
"retention sweep: failed to reap expired events",
|
"retention sweep: failed to reap expired events",
|
||||||
@@ -280,107 +265,57 @@ func retentionCutoff(
|
|||||||
), true
|
), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapExpired hard-deletes the events older than cutoff, with their
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||||
// deliveries and delivery results, reapBatchSize events per
|
// results, deliveries, and events associated with events older than
|
||||||
// transaction with reapBatchPause between transactions, until none is
|
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||||
// left. Once ctx is cancelled it returns after the batch in hand,
|
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||||
// leaving the rest to the next sweep, so stopping the app does not
|
// deleted.
|
||||||
// wait for a long prune. It returns the number of events deleted.
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
func reapExpired(
|
// Fresh subqueries are built per statement to avoid reusing a
|
||||||
ctx context.Context, db *gorm.DB, cutoff time.Time,
|
// mutated builder across executions.
|
||||||
) (int64, error) {
|
expiredEventIDs := func() *gorm.DB {
|
||||||
var total int64
|
return db.Model(&Event{}).
|
||||||
|
|
||||||
for {
|
|
||||||
var eventIDs []string
|
|
||||||
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Unscoped().Model(&Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(reapBatchSize).
|
|
||||||
Pluck("id", &eventIDs).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("selecting expired events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(eventIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteEvents(tx, eventIDs)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total += int64(len(eventIDs))
|
|
||||||
|
|
||||||
if len(eventIDs) < reapBatchSize {
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return total, nil
|
|
||||||
case <-time.After(reapBatchPause):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
|
||||||
// order before them, their delivery results and deliveries, then adds
|
|
||||||
// what it deleted to the running totals. It runs on reapExpired's
|
|
||||||
// transaction, so the totals change exactly when the rows do. Deletes
|
|
||||||
// are unscoped so rows are physically removed rather than
|
|
||||||
// soft-deleted, reclaiming disk.
|
|
||||||
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
|
||||||
// 1. The delivery results of the events' deliveries.
|
|
||||||
err := tx.Unscoped().
|
|
||||||
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("event_id IN ?", eventIDs)).
|
Where("created_at < ?", cutoff)
|
||||||
Delete(&DeliveryResult{}).Error
|
}
|
||||||
if err != nil {
|
expiredDeliveryIDs := func() *gorm.DB {
|
||||||
return fmt.Errorf("deleting expired delivery results: %w", err)
|
return db.Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN (?)", expiredEventIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. The events' deliveries, after counting them, and the failed
|
// 1. Delivery results whose delivery belongs to an expired event.
|
||||||
// ones among them, per target. The status is tested in the select
|
res := db.Unscoped().
|
||||||
// list rather than the WHERE clause: there, SQLite would read every
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||||
// failed delivery the webhook has through the status index,
|
Delete(&DeliveryResult{})
|
||||||
// instead of only these through the event_id index.
|
if res.Error != nil {
|
||||||
var removed []TargetTotals
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired delivery results: %w",
|
||||||
err = tx.Unscoped().Model(&Delivery{}).
|
res.Error,
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
)
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
|
||||||
DeliveryStatusFailed).
|
|
||||||
Where("event_id IN ?", eventIDs).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&removed).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting expired deliveries: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Unscoped().
|
// 2. Deliveries belonging to an expired event.
|
||||||
Where("event_id IN ?", eventIDs).
|
del := db.Unscoped().
|
||||||
Delete(&Delivery{}).Error
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
if err != nil {
|
Delete(&Delivery{})
|
||||||
return fmt.Errorf("deleting expired deliveries: %w", err)
|
if del.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired deliveries: %w",
|
||||||
|
del.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The events themselves.
|
// 3. The expired events themselves.
|
||||||
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
ev := db.Unscoped().
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Delete(&Event{})
|
||||||
if ev.Error != nil {
|
if ev.Error != nil {
|
||||||
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired events: %w",
|
||||||
|
ev.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range removed {
|
return ev.RowsAffected, nil
|
||||||
err = AddTargetTotals(tx, removed[i])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readEventTotals reads a webhook database's row of event totals,
|
|
||||||
// asserting that it has exactly one.
|
|
||||||
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.EventTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func readTargetTotals(
|
|
||||||
t *testing.T, db *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
|
||||||
// database starts with one row of zero event totals and no target
|
|
||||||
// totals, that adding to a target twice adds to the one row, and that
|
|
||||||
// opening the database again keeps everything added.
|
|
||||||
func TestWebhookDBManager_TotalsSurviveReopen(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 := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
|
||||||
assert.Empty(t, readTargetTotals(t, db))
|
|
||||||
|
|
||||||
first, second := uuid.New().String(), uuid.New().String()
|
|
||||||
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: 2,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Failed: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: second, Deliveries: 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.EventTotals{ID: fresh.ID, Events: 2},
|
|
||||||
readEventTotals(t, db))
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
|
||||||
},
|
|
||||||
second: {TargetID: second, Deliveries: 1},
|
|
||||||
}, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedExpiredEvents stores count events created at the given time,
|
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
|
||||||
// to the other, and one attempt for each delivery.
|
|
||||||
func seedExpiredEvents(
|
|
||||||
t *testing.T,
|
|
||||||
db *gorm.DB,
|
|
||||||
webhookID string,
|
|
||||||
count int,
|
|
||||||
createdAt time.Time,
|
|
||||||
delivered, failed string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := make([]database.Event, count)
|
|
||||||
deliveries := make([]database.Delivery, 0, 2*count)
|
|
||||||
|
|
||||||
for i := range events {
|
|
||||||
events[i] = database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
events[i].ID = uuid.New().String()
|
|
||||||
events[i].CreatedAt = createdAt
|
|
||||||
|
|
||||||
deliveries = append(deliveries,
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: delivered,
|
|
||||||
Status: database.DeliveryStatusDelivered,
|
|
||||||
},
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: failed,
|
|
||||||
Status: database.DeliveryStatusFailed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
|
||||||
for i := range deliveries {
|
|
||||||
results[i] = database.DeliveryResult{
|
|
||||||
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedBareEvents stores count events created at the given time, with
|
|
||||||
// no deliveries.
|
|
||||||
func seedBareEvents(
|
|
||||||
t *testing.T,
|
|
||||||
db *gorm.DB,
|
|
||||||
webhookID string,
|
|
||||||
count int,
|
|
||||||
createdAt time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := make([]database.Event, count)
|
|
||||||
for i := range events {
|
|
||||||
events[i] = database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
events[i].CreatedAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
|
||||||
// larger than one transaction's batch removes every expired event with
|
|
||||||
// its deliveries and delivery results, keeps the recent event, and
|
|
||||||
// adds what it removed to the event and target totals, so the totals
|
|
||||||
// within retention match the rows still stored.
|
|
||||||
func TestRetentionReaper_PrunesMoreThanOneBatch(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)
|
|
||||||
|
|
||||||
expired := database.ExportReapBatchSize + 1
|
|
||||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
|
||||||
seedExpiredEvents(t, db, webhookID, expired,
|
|
||||||
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
|
||||||
|
|
||||||
// One recent event, delivered to the first target.
|
|
||||||
recent := seedEventChain(t, db, webhookID, time.Now())
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", recent.deliveryID).
|
|
||||||
Update("target_id", delivered).Error)
|
|
||||||
|
|
||||||
// The totals storing those rows would have left.
|
|
||||||
n := int64(expired)
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
// Only the recent event's rows are left.
|
|
||||||
for _, model := range []any{
|
|
||||||
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
|
||||||
} {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(model).Count(&count).Error)
|
|
||||||
assert.Equal(t, int64(1), count, "%T rows left", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertChainPresent(t, db, recent)
|
|
||||||
|
|
||||||
eventTotals := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{
|
|
||||||
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
|
||||||
}, eventTotals)
|
|
||||||
|
|
||||||
targetTotals := readTargetTotals(t, db)
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
delivered: {
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
DeliveriesRemoved: n,
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
DeliveriesRemoved: n, FailedRemoved: n,
|
|
||||||
},
|
|
||||||
}, targetTotals)
|
|
||||||
|
|
||||||
// A sweep with nothing left to remove changes nothing.
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
|
||||||
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
|
|
||||||
// of several batches lets other writers in between its batches: an
|
|
||||||
// event stored once the first batch is deleted is stored while expired
|
|
||||||
// events are still left, not only after the prune has finished.
|
|
||||||
func TestRetentionReaper_WriteDuringPruneSucceeds(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)
|
|
||||||
|
|
||||||
// Three batches of expired events, with nothing else stored: only
|
|
||||||
// the number of batches matters here.
|
|
||||||
expired := 3 * database.ExportReapBatchSize
|
|
||||||
seedBareEvents(t, db, webhookID, expired,
|
|
||||||
time.Now().Add(-40*24*time.Hour))
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
|
||||||
countExpired := func() int64 {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Count(&count).Error)
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
pruned := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(pruned)
|
|
||||||
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() { <-pruned })
|
|
||||||
|
|
||||||
// Every stored event is expired until the write below.
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).Count(&count).Error
|
|
||||||
|
|
||||||
return err == nil && count < int64(expired)
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.Create(event).Error)
|
|
||||||
|
|
||||||
assert.Positive(t, countExpired(),
|
|
||||||
"the event was stored only after the whole prune")
|
|
||||||
|
|
||||||
<-pruned
|
|
||||||
|
|
||||||
assert.Zero(t, countExpired())
|
|
||||||
|
|
||||||
var stored database.Event
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
|
|
||||||
// stopping the reaper during a prune of several batches returns
|
|
||||||
// between two batches, well inside the stop timeout, leaving the
|
|
||||||
// remaining expired events for the next sweep, and that the totals
|
|
||||||
// match the rows left.
|
|
||||||
func TestRetentionReaper_StopDuringPruneLeavesTheRest(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)
|
|
||||||
|
|
||||||
// Two batches and one more of expired events, a few of them with a
|
|
||||||
// delivered and a failed delivery for the target totals to count.
|
|
||||||
// Most carry nothing else, to keep the test quick.
|
|
||||||
const withDeliveries = 10
|
|
||||||
|
|
||||||
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
|
|
||||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
|
||||||
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
|
|
||||||
delivered, failed)
|
|
||||||
seedBareEvents(t, db, webhookID,
|
|
||||||
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
|
|
||||||
|
|
||||||
n := int64(2*database.ExportReapBatchSize + 1)
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: n,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: delivered, Deliveries: withDeliveries,
|
|
||||||
Delivered: withDeliveries,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: failed, Deliveries: withDeliveries,
|
|
||||||
Failed: withDeliveries,
|
|
||||||
}))
|
|
||||||
|
|
||||||
env.reaper.ExportSetInterval(time.Millisecond)
|
|
||||||
env.reaper.ExportStart()
|
|
||||||
|
|
||||||
// Stop once the first batch is deleted. The stop lands in the pause
|
|
||||||
// after it, or at worst during the second batch, so at least the
|
|
||||||
// last event is left.
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).Count(&count).Error
|
|
||||||
|
|
||||||
return err == nil && count < n
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
// The app's stop timeout.
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
require.NoError(t, env.reaper.ExportStop(ctx))
|
|
||||||
|
|
||||||
var events int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
|
|
||||||
assert.Positive(t, events, "the stop waited for the whole prune")
|
|
||||||
|
|
||||||
eventTotals := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
|
|
||||||
|
|
||||||
targetTotals := readTargetTotals(t, db)
|
|
||||||
require.Len(t, targetTotals, 2)
|
|
||||||
|
|
||||||
for target, totals := range targetTotals {
|
|
||||||
var deliveries, failures int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("target_id = ?", target).
|
|
||||||
Count(&deliveries).Error)
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("target_id = ? AND status = ?",
|
|
||||||
target, database.DeliveryStatusFailed).
|
|
||||||
Count(&failures).Error)
|
|
||||||
|
|
||||||
assert.Equal(t, deliveries,
|
|
||||||
totals.Deliveries-totals.DeliveriesRemoved, target)
|
|
||||||
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
|
|
||||||
target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,7 @@ 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, DeliveryResults and the
|
// database containing Events, Deliveries, and DeliveryResults.
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -296,7 +295,6 @@ 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{},
|
||||||
&EventTotals{}, &TargetTotals{},
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -307,18 +305,6 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new database gets its row of event totals, all zero. Target
|
|
||||||
// totals rows are created by the first delivery to each target.
|
|
||||||
err = db.FirstOrCreate(&EventTotals{}).Error
|
|
||||||
if err != nil {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"creating event 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,
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// targetTotals reads one target's totals from a webhook database, all
|
|
||||||
// zero when it has no row.
|
|
||||||
func targetTotals(
|
|
||||||
t *testing.T, db *gorm.DB, targetID string,
|
|
||||||
) database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Where("target_id = ?", targetID).
|
|
||||||
Find(&rows).Error)
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return database.TargetTotals{TargetID: targetID}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals 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 its target's delivered or failed total.
|
|
||||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
status database.DeliveryStatus
|
|
||||||
finished bool
|
|
||||||
delivered int64
|
|
||||||
failed int64
|
|
||||||
}{
|
|
||||||
{database.DeliveryStatusRetrying, false, 0, 0},
|
|
||||||
{database.DeliveryStatusDelivered, true, 1, 0},
|
|
||||||
{database.DeliveryStatusFailed, true, 0, 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, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
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, database.TargetTotals{
|
|
||||||
TargetID: targetID,
|
|
||||||
Delivered: tt.delivered,
|
|
||||||
Failed: tt.failed,
|
|
||||||
}, targetTotals(t, db, targetID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, db.Unscoped().
|
|
||||||
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, database.DeliveryStatusFailed,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
|
||||||
targetTotals(t, db, targetID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
|
|
||||||
// delivery settled a second time, as recovery can do when a worker has
|
|
||||||
// settled it since recovery read it. Neither status writes over the
|
|
||||||
// first, and the totals do not move.
|
|
||||||
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
finished := []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, first := range finished {
|
|
||||||
t.Run(string(first), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The delivery as recovery read it, before the worker
|
|
||||||
// settled it.
|
|
||||||
readBefore := d
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, first,
|
|
||||||
))
|
|
||||||
|
|
||||||
var settled database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
|
|
||||||
require.NotNil(t, settled.FinishedAt)
|
|
||||||
|
|
||||||
totals := targetTotals(t, db, targetID)
|
|
||||||
|
|
||||||
for _, again := range finished {
|
|
||||||
stale := readBefore
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &stale, again,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
|
||||||
assert.Equal(t, first, stored.Status)
|
|
||||||
require.NotNil(t, stored.FinishedAt)
|
|
||||||
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
|
|
||||||
assert.Equal(t, totals, targetTotals(t, db, targetID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
|
||||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
@@ -147,10 +146,8 @@ type EngineParams struct {
|
|||||||
|
|
||||||
DB *database.Database
|
DB *database.Database
|
||||||
DBManager *database.WebhookDBManager
|
DBManager *database.WebhookDBManager
|
||||||
Globals *globals.Globals
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
SSRFGuard *Guard
|
SSRFGuard *Guard
|
||||||
Metrics *metrics.Set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine processes queued deliveries in the background
|
// Engine processes queued deliveries in the background
|
||||||
@@ -170,14 +167,10 @@ type Engine struct {
|
|||||||
retryCh chan Task
|
retryCh chan Task
|
||||||
workers int
|
workers int
|
||||||
|
|
||||||
// version is the running build's version, the one the web UI
|
// mtr is the delivery metric set. Production wires the
|
||||||
// footer shows. userAgent puts it on every outbound request.
|
// process-wide one; a test can substitute a set registered on
|
||||||
version string
|
// a private registry so its assertions are not disturbed by
|
||||||
|
// deliveries other tests are making at the same time.
|
||||||
// mtr is the delivery metric set. Production wires the one
|
|
||||||
// registered on the registry /metrics serves; a test can
|
|
||||||
// substitute a set registered on a registry it holds, so it can
|
|
||||||
// gather what its own deliveries recorded.
|
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
|
|
||||||
// targets maps each target type to its implementation.
|
// targets maps each target type to its implementation.
|
||||||
@@ -211,8 +204,7 @@ func New(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: defaultWorkers,
|
workers: defaultWorkers,
|
||||||
version: params.Globals.Version,
|
mtr: metrics.Default(),
|
||||||
mtr: params.Metrics,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.initTargets(&http.Client{
|
e.initTargets(&http.Client{
|
||||||
@@ -308,13 +300,6 @@ func (e *Engine) ScheduleRetry(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgent is the User-Agent header of every http and slack
|
|
||||||
// delivery request: the program name and the running build's
|
|
||||||
// version.
|
|
||||||
func (e *Engine) userAgent() string {
|
|
||||||
return "webhooker/" + e.version
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerHooks wires the engine's start and stop into the fx
|
// registerHooks wires the engine's start and stop into the fx
|
||||||
// lifecycle. The start hook's context is deliberately ignored
|
// lifecycle. The start hook's context is deliberately ignored
|
||||||
// (see start for why the worker pool must not inherit it); the
|
// (see start for why the worker pool must not inherit it); the
|
||||||
@@ -546,11 +531,6 @@ func (e *Engine) processRetryTask(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set before anything below can fail the delivery: the failure is
|
|
||||||
// added to this target's totals.
|
|
||||||
d.EventID = task.EventID
|
|
||||||
d.TargetID = task.TargetID
|
|
||||||
|
|
||||||
if d.Status != database.DeliveryStatusRetrying {
|
if d.Status != database.DeliveryStatusRetrying {
|
||||||
e.log.Debug(
|
e.log.Debug(
|
||||||
"skipping retry for delivery "+
|
"skipping retry for delivery "+
|
||||||
@@ -582,6 +562,8 @@ func (e *Engine) processRetryTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := buildTargetFromTask(task)
|
target := buildTargetFromTask(task)
|
||||||
|
d.EventID = task.EventID
|
||||||
|
d.TargetID = task.TargetID
|
||||||
d.Event = event
|
d.Event = event
|
||||||
d.Target = target
|
d.Target = target
|
||||||
|
|
||||||
@@ -1572,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Model(d).
|
||||||
return writeDeliveryStatus(tx, d, status)
|
Update("status", status).Error
|
||||||
})
|
|
||||||
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",
|
||||||
@@ -1593,43 +1574,6 @@ 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 is
|
|
||||||
// added to its target's delivered or failed total. That write changes
|
|
||||||
// only a delivery not yet delivered or failed, and the total moves
|
|
||||||
// only when it changed a row: retention may have deleted the delivery
|
|
||||||
// while the engine was working on it, and a recovery path may settle
|
|
||||||
// a delivery that a worker has already settled.
|
|
||||||
func writeDeliveryStatus(
|
|
||||||
tx *gorm.DB,
|
|
||||||
d *database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) error {
|
|
||||||
if !status.Terminal() {
|
|
||||||
return tx.Model(d).Update("status", status).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
res := tx.Model(d).
|
|
||||||
Where("status NOT IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"status": status,
|
|
||||||
"finished_at": time.Now(),
|
|
||||||
})
|
|
||||||
if res.Error != nil || res.RowsAffected == 0 {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
&database.EventTotals{},
|
|
||||||
&database.TargetTotals{},
|
|
||||||
))
|
))
|
||||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1244,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
testContentType,
|
testContentType,
|
||||||
receivedHeaders.Get("Content-Type"),
|
receivedHeaders.Get("Content-Type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
"webhooker/1.0",
|
||||||
|
receivedHeaders.Get("User-Agent"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
@@ -1315,7 +1317,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
ContentType: tc.event,
|
ContentType: tc.event,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
"webhooker/dev",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -41,6 +40,11 @@ const (
|
|||||||
ExportPendingSweepMinAge = pendingSweepMinAge
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
||||||
|
func ExportIsBlockedIP(ip net.IP) bool {
|
||||||
|
return isBlockedIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
// NewTestGuard builds an SSRF Guard from an explicit egress
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
||||||
// allowlist, without going through config. Passing no prefixes
|
// allowlist, without going through config. Passing no prefixes
|
||||||
// yields the default guard, which blocks every private/reserved
|
// yields the default guard, which blocks every private/reserved
|
||||||
@@ -66,11 +70,6 @@ func ExportBlockedNetworks() []*net.IPNet {
|
|||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
|
||||||
func ExportBlockedPublicNetworks() []*net.IPNet {
|
|
||||||
return blockedPublicNetworks
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||||
func ExportIsForwardableHeader(name string) bool {
|
func ExportIsForwardableHeader(name string) bool {
|
||||||
return isForwardableHeader(name)
|
return isForwardableHeader(name)
|
||||||
@@ -83,9 +82,8 @@ func ExportApplyRequestHeaders(
|
|||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
userAgent string,
|
|
||||||
) []string {
|
) []string {
|
||||||
return applyRequestHeaders(req, event, cfg, userAgent)
|
return applyRequestHeaders(req, event, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportTruncate exposes truncate for testing.
|
// ExportTruncate exposes truncate for testing.
|
||||||
@@ -152,16 +150,6 @@ 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,
|
||||||
@@ -401,7 +389,7 @@ func NewTestEngine(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -416,7 +404,7 @@ func NewTestEngineSmallRetry(
|
|||||||
e := &Engine{
|
e := &Engine{
|
||||||
log: log,
|
log: log,
|
||||||
retryCh: make(chan Task, 1),
|
retryCh: make(chan Task, 1),
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(nil)
|
e.initTargets(nil)
|
||||||
|
|
||||||
@@ -439,7 +427,7 @@ func NewTestEngineWithDB(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -447,7 +435,8 @@ func NewTestEngineWithDB(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
||||||
// assert on collectors registered on a registry it holds.
|
// assert on collectors registered on a private registry instead of
|
||||||
|
// the process-wide ones every other test is also moving.
|
||||||
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
||||||
e.mtr = mtr
|
e.mtr = mtr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// mIsolate gives the setup's engine a metric set registered on a
|
// mIsolate gives the setup's engine a metric set registered on a
|
||||||
// registry this test holds, so its exact assertions can gather from
|
// private registry. The process-wide collectors are moved by every
|
||||||
// it.
|
// other delivery test running in parallel, so exact assertions are
|
||||||
|
// only possible against a registry this test owns.
|
||||||
func mIsolate(
|
func mIsolate(
|
||||||
t *testing.T, s iSetup,
|
t *testing.T, s iSetup,
|
||||||
) *prometheus.Registry {
|
) *prometheus.Registry {
|
||||||
|
|||||||
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
"Content-Type": testContentType,
|
"Content-Type": testContentType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"webhooker/dev",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
+32
-104
@@ -25,64 +25,36 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
// ErrBlockedPrivateOrReservedIP reports an address in the
|
errBlockedIP = errors.New(
|
||||||
// default blocklist's private and reserved ranges,
|
"blocked private, reserved or cloud metadata address",
|
||||||
// blockedNetworks.
|
|
||||||
ErrBlockedPrivateOrReservedIP = errors.New(
|
|
||||||
"blocked private or reserved address",
|
|
||||||
)
|
|
||||||
// errBlockedPublicMetadata reports a public address on the
|
|
||||||
// default blocklist, one in blockedPublicNetworks.
|
|
||||||
errBlockedPublicMetadata = errors.New(
|
|
||||||
"blocked cloud metadata address",
|
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local, cloud instance metadata or " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
|
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||||
)
|
)
|
||||||
errInvalidScheme = errors.New(
|
errInvalidScheme = errors.New(
|
||||||
"only http and https are allowed",
|
"only http and https are allowed",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks and blockedPublicNetworks together are the
|
// blockedNetworks is the default blocklist: the private and
|
||||||
// default blocklist: the private and reserved IP ranges, plus
|
// reserved IP ranges, plus the public cloud metadata addresses,
|
||||||
// the public cloud metadata addresses, that are blocked to
|
// that are blocked to prevent SSRF attacks. An operator can
|
||||||
// prevent SSRF attacks. An operator can permit specific blocks
|
// permit specific blocks out of this set with
|
||||||
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
// ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
|
||||||
// blockedNetworks holds the private and reserved IP ranges.
|
|
||||||
//
|
//
|
||||||
//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
|
||||||
|
|
||||||
// blockedPublicNetworks holds the default blocklist's public
|
|
||||||
// addresses, kept apart from blockedNetworks so that they are
|
|
||||||
// refused as cloud metadata addresses, never as private or
|
|
||||||
// reserved ones.
|
|
||||||
//
|
|
||||||
// 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; it goes
|
|
||||||
// in this list. 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
|
|
||||||
var blockedPublicNetworks []*net.IPNet
|
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open, so a supplied CIDR that covers one still leaves it
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// blocked. An entry is here for one of two reasons: it is a
|
// endpoints that live outside them. Reaching one is credential
|
||||||
// metadata endpoint (the link-local blocks and the cloud
|
// or user-data theft rather than delivery to an internal
|
||||||
// instance metadata endpoints that live outside them), or it is
|
// service, so a supplied CIDR that covers such an address still
|
||||||
// an unspecified address. Reaching a metadata endpoint is
|
// leaves it blocked.
|
||||||
// credential or user-data theft rather than delivery to an
|
|
||||||
// internal service.
|
|
||||||
//
|
//
|
||||||
// Inclusion criterion for metadata endpoints — one belongs here
|
// Inclusion criterion — an address belongs here only if BOTH
|
||||||
// only if BOTH hold, and every metadata entry below satisfies
|
// hold, and every entry below satisfies both:
|
||||||
// both:
|
|
||||||
//
|
//
|
||||||
// 1. It is a fixed address assigned by the provider, or a
|
// 1. It is a fixed address assigned by the provider, or a
|
||||||
// range reserved by IANA — never one the operator chose.
|
// range reserved by IANA — never one the operator chose.
|
||||||
@@ -93,8 +65,8 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// not cheaply rotated.
|
// not cheaply rotated.
|
||||||
//
|
//
|
||||||
// Both halves are load-bearing, so use them to refuse a
|
// Both halves are load-bearing, so use them to refuse a
|
||||||
// metadata candidate and say why. An endpoint disclosing only
|
// candidate and say why. An endpoint disclosing only the
|
||||||
// the operator's own inventory (instance id, region, disks, NICs)
|
// operator's own inventory (instance id, region, disks, NICs)
|
||||||
// fails (2): letting a delivery target reach the operator's own
|
// fails (2): letting a delivery target reach the operator's own
|
||||||
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
||||||
// provide. But (2) is not "IAM credentials only" either —
|
// provide. But (2) is not "IAM credentials only" either —
|
||||||
@@ -109,21 +81,12 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// when it clears both halves. Nothing in this list can be
|
// when it clears both halves. Nothing in this list can be
|
||||||
// reopened, so putting a public address here leaves the operator
|
// reopened, so putting a public address here leaves the operator
|
||||||
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
||||||
// exists to remove. Default-block it in blockedPublicNetworks
|
// exists to remove. Default-block it in blockedNetworks instead,
|
||||||
// instead, which an allowlist can override.
|
// which an allowlist can override.
|
||||||
//
|
//
|
||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
//
|
//
|
||||||
// The unspecified addresses 0.0.0.0 and :: are here for a
|
|
||||||
// separate reason: they disclose nothing, but no host can have
|
|
||||||
// either, and on Linux a connection to one reaches this host's
|
|
||||||
// own loopback. Listing them means an allowlist reaches loopback
|
|
||||||
// only through an entry that covers a loopback address
|
|
||||||
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
|
|
||||||
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
|
|
||||||
// else lives at either address, so refusing them costs nothing.
|
|
||||||
//
|
|
||||||
// Every entry is either already in blockedNetworks — this list is
|
// Every entry is either already in blockedNetworks — this list is
|
||||||
// what makes it unconditional — or an alternate encoding of
|
// what makes it unconditional — or an alternate encoding of
|
||||||
// 169.254.169.254 that Contains does not match against
|
// 169.254.169.254 that Contains does not match against
|
||||||
@@ -143,49 +106,23 @@ var alwaysBlockedNetworks []*net.IPNet
|
|||||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
func init() {
|
func init() {
|
||||||
blockedNetworks = mustParseCIDRs([]string{
|
blockedNetworks = mustParseCIDRs([]string{
|
||||||
// IPv4 loopback.
|
|
||||||
"127.0.0.0/8",
|
"127.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"10.0.0.0/8",
|
"10.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"172.16.0.0/12",
|
"172.16.0.0/12",
|
||||||
// RFC 1918 private network.
|
|
||||||
"192.168.0.0/16",
|
"192.168.0.0/16",
|
||||||
// IPv4 link-local.
|
|
||||||
"169.254.0.0/16",
|
"169.254.0.0/16",
|
||||||
// "This network", holding the IPv4 unspecified address 0.0.0.0.
|
|
||||||
"0.0.0.0/8",
|
"0.0.0.0/8",
|
||||||
// Carrier-grade NAT shared address space.
|
|
||||||
"100.64.0.0/10",
|
"100.64.0.0/10",
|
||||||
// IETF protocol assignments.
|
|
||||||
"192.0.0.0/24",
|
"192.0.0.0/24",
|
||||||
// IPv4 documentation (TEST-NET-1).
|
|
||||||
"192.0.2.0/24",
|
"192.0.2.0/24",
|
||||||
// Benchmarking.
|
|
||||||
"198.18.0.0/15",
|
"198.18.0.0/15",
|
||||||
// IPv4 documentation (TEST-NET-2).
|
|
||||||
"198.51.100.0/24",
|
"198.51.100.0/24",
|
||||||
// IPv4 documentation (TEST-NET-3).
|
|
||||||
"203.0.113.0/24",
|
"203.0.113.0/24",
|
||||||
// IPv4 multicast.
|
|
||||||
"224.0.0.0/4",
|
"224.0.0.0/4",
|
||||||
// Reserved, including the broadcast address.
|
|
||||||
"240.0.0.0/4",
|
"240.0.0.0/4",
|
||||||
// IPv6 loopback.
|
|
||||||
"::1/128",
|
"::1/128",
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
// IPv6 unique local addresses.
|
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
// IPv6 link-local.
|
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// IPv6 multicast.
|
|
||||||
"ff00::/8",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"2001:db8::/32",
|
|
||||||
})
|
|
||||||
|
|
||||||
blockedPublicNetworks = mustParseCIDRs([]string{
|
|
||||||
// Azure WireServer, a public address that serves VM credentials.
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
"168.63.129.16/32",
|
"168.63.129.16/32",
|
||||||
})
|
})
|
||||||
@@ -242,14 +179,6 @@ func init() {
|
|||||||
// allowlist from opening it.
|
// allowlist from opening it.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
|
|
||||||
// The unspecified addresses, each of which reaches this
|
|
||||||
// host's loopback on Linux.
|
|
||||||
//
|
|
||||||
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -289,6 +218,13 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBlockedIP checks whether an IP address falls within
|
||||||
|
// the default blocklist, before any operator allowlist is
|
||||||
|
// considered.
|
||||||
|
func isBlockedIP(ip net.IP) bool {
|
||||||
|
return matchesAny(blockedNetworks, ip)
|
||||||
|
}
|
||||||
|
|
||||||
// Guard makes every SSRF decision in the process.
|
// Guard makes every SSRF decision in the process.
|
||||||
//
|
//
|
||||||
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
||||||
@@ -386,12 +322,10 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// The order is the policy:
|
// The order is the policy:
|
||||||
//
|
//
|
||||||
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
||||||
// consulted, so no configured CIDR reaches link-local, a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address, or an
|
// cloud metadata endpoint at a non-public address.
|
||||||
// unspecified address.
|
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network, or a listed public address on the default
|
// network becomes reachable.
|
||||||
// blocklist, becomes reachable.
|
|
||||||
// 3. Everything else keeps the default blocklist's answer.
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
func (g *Guard) checkIP(ip net.IP) error {
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
if matchesAny(alwaysBlockedNetworks, ip) {
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
@@ -404,15 +338,9 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchesAny(blockedNetworks, ip) {
|
if isBlockedIP(ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
"target IP %s: %w", ip, errBlockedIP,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchesAny(blockedPublicNetworks, ip) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ const (
|
|||||||
metadataIP = "169.254.169.254"
|
metadataIP = "169.254.169.254"
|
||||||
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
// linkLocalIPv4 is the IPv4 link-local block, which holds
|
|
||||||
// metadataIP.
|
|
||||||
linkLocalIPv4 = "169.254.0.0/16"
|
|
||||||
|
|
||||||
// loopbackHookURL is a target on this host: blocked by
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
// default, reachable only once an operator allowlists
|
// default, reachable only once an operator allowlists
|
||||||
// loopback.
|
// loopback.
|
||||||
@@ -168,13 +164,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
|||||||
|
|
||||||
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
||||||
// case: cloud instance metadata endpoints are credential theft
|
// case: cloud instance metadata endpoints are credential theft
|
||||||
// rather than delivery to an internal service, and the
|
// rather than delivery to an internal service, so no allowlist
|
||||||
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
// reaches one. Every guard below names a CIDR that covers its
|
||||||
// on Linux, so no allowlist reaches any of them. Every guard
|
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
||||||
// below names a CIDR that covers its target — including
|
// CGNAT blocks an operator would really list — and the address
|
||||||
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
// must stay refused anyway, on both the validation and the
|
||||||
// operator would really list — and the address must stay
|
// delivery path.
|
||||||
// refused anyway, on both the validation and the delivery path.
|
|
||||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -220,17 +215,15 @@ type metadataAlwaysRefusedCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||||
// blocked address (link-local, the cloud metadata endpoints and
|
// blocked address together with an allowlist entry that would
|
||||||
// the unspecified addresses) together with an allowlist entry
|
// otherwise reach it. Split by family of address only to stay
|
||||||
// that would otherwise reach it. Split by family of address only
|
// under the function-length limit.
|
||||||
// to stay under the function-length limit.
|
|
||||||
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
cases := linkLocalRefusedCases()
|
cases := linkLocalRefusedCases()
|
||||||
cases = append(cases, ulaMetadataRefusedCases()...)
|
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||||
cases = append(cases, ipv4MetadataRefusedCases()...)
|
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||||
cases = append(cases, encodedMetadataRefusedCases()...)
|
|
||||||
|
|
||||||
return append(cases, unspecifiedRefusedCases()...)
|
return append(cases, encodedMetadataRefusedCases()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkLocalRefusedCases covers the link-local blocks, including
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
@@ -244,7 +237,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: linkLocalIPv4,
|
allow: "169.254.0.0/16",
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -370,23 +363,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unspecifiedRefusedCases covers the unspecified addresses, each
|
|
||||||
// of which reaches this host's loopback on Linux.
|
|
||||||
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
|
|
||||||
return []metadataAlwaysRefusedCase{
|
|
||||||
{
|
|
||||||
name: "IPv4 unspecified address under 0.0.0.0/0",
|
|
||||||
allow: allowAllIPv4,
|
|
||||||
target: "http://0.0.0.0:8080/hook",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 unspecified address under ::/0",
|
|
||||||
allow: allowAllIPv6,
|
|
||||||
target: "http://[::]:8080/hook",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
||||||
// not narrow anything: public addresses were reachable before it
|
// not narrow anything: public addresses were reachable before it
|
||||||
// existed and stay reachable, whether or not a list is set.
|
// existed and stay reachable, whether or not a list is set.
|
||||||
@@ -436,9 +412,6 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
|||||||
"WireServer must be refused by the default blocklist, "+
|
"WireServer must be refused by the default blocklist, "+
|
||||||
"which an allowlist can override",
|
"which an allowlist can override",
|
||||||
)
|
)
|
||||||
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
|
|
||||||
"WireServer is public, not private or reserved",
|
|
||||||
)
|
|
||||||
|
|
||||||
assertDialRefused(t, defaultGuard, target)
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
@@ -523,7 +496,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
// IPv4 link-local: the 169.254.169.254 metadata
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
// service on AWS, Azure and others.
|
// service on AWS, Azure and others.
|
||||||
linkLocalIPv4,
|
"169.254.0.0/16",
|
||||||
// IPv6 link-local.
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// AWS IPv6 IMDS, inside the ULA space an operator may
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
@@ -544,10 +517,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
// Oracle Cloud Classic metadata, inside the blocked
|
// Oracle Cloud Classic metadata, inside the blocked
|
||||||
// 192.0.0.0/24.
|
// 192.0.0.0/24.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
// The IPv4 and IPv6 unspecified addresses, each of
|
|
||||||
// which reaches this host's loopback on Linux.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
"::/128",
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -557,94 +526,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultBlocklist_PinnedSet pins each list of the default
|
|
||||||
// blocklist on its own, the private and reserved ranges in
|
|
||||||
// blockedNetworks and the public addresses in
|
|
||||||
// blockedPublicNetworks, so moving an entry from one list to the
|
|
||||||
// other fails it. For the first address of each entry it then
|
|
||||||
// checks that the default guard refuses it, and that listing the
|
|
||||||
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
|
|
||||||
// set holds that address.
|
|
||||||
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// public marks an entry of blockedPublicNetworks; every other
|
|
||||||
// entry belongs in blockedNetworks.
|
|
||||||
tests := []struct {
|
|
||||||
cidr string
|
|
||||||
public bool
|
|
||||||
reopenable bool
|
|
||||||
}{
|
|
||||||
{cidr: "127.0.0.0/8", reopenable: true},
|
|
||||||
{cidr: "10.0.0.0/8", reopenable: true},
|
|
||||||
{cidr: "172.16.0.0/12", reopenable: true},
|
|
||||||
{cidr: "192.168.0.0/16", reopenable: true},
|
|
||||||
{cidr: linkLocalIPv4, reopenable: false},
|
|
||||||
// Its first address, 0.0.0.0, is in the unconditional set.
|
|
||||||
{cidr: "0.0.0.0/8", reopenable: false},
|
|
||||||
{cidr: "100.64.0.0/10", reopenable: true},
|
|
||||||
{cidr: "192.0.0.0/24", reopenable: true},
|
|
||||||
{cidr: "192.0.2.0/24", reopenable: true},
|
|
||||||
{cidr: "198.18.0.0/15", reopenable: true},
|
|
||||||
{cidr: "198.51.100.0/24", reopenable: true},
|
|
||||||
{cidr: "203.0.113.0/24", reopenable: true},
|
|
||||||
{cidr: "224.0.0.0/4", reopenable: true},
|
|
||||||
{cidr: "240.0.0.0/4", reopenable: true},
|
|
||||||
{cidr: "::1/128", reopenable: true},
|
|
||||||
{cidr: "::/128", reopenable: false},
|
|
||||||
{cidr: "fc00::/7", reopenable: true},
|
|
||||||
{cidr: "fe80::/10", reopenable: false},
|
|
||||||
{cidr: "ff00::/8", reopenable: true},
|
|
||||||
{cidr: "2001:db8::/32", reopenable: true},
|
|
||||||
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPrivate := make([]string, 0, len(tests))
|
|
||||||
wantPublic := make([]string, 0, len(tests))
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
if tt.public {
|
|
||||||
wantPublic = append(wantPublic, tt.cidr)
|
|
||||||
} else {
|
|
||||||
wantPrivate = append(wantPrivate, tt.cidr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gotPrivate := make([]string, 0, len(tests))
|
|
||||||
for _, n := range delivery.ExportBlockedNetworks() {
|
|
||||||
gotPrivate = append(gotPrivate, n.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
gotPublic := make([]string, 0, len(tests))
|
|
||||||
for _, n := range delivery.ExportBlockedPublicNetworks() {
|
|
||||||
gotPublic = append(gotPublic, n.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
|
|
||||||
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.cidr, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
prefix := netip.MustParsePrefix(tt.cidr)
|
|
||||||
ip := net.IP(prefix.Addr().AsSlice())
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
delivery.NewTestGuard().ExportCheckIP(ip),
|
|
||||||
"the default guard must refuse %s", ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
|
|
||||||
if tt.reopenable {
|
|
||||||
assert.NoError(t, err, "listing %s must open it", tt.cidr)
|
|
||||||
} else {
|
|
||||||
assert.Error(t, err, "listing %s must not open it", tt.cidr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireLoopback fails the test unless rawURL's host is a
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
// loopback address, so the allowlist test cannot silently stop
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
// exercising a blocked range.
|
// exercising a blocked range.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,14 +56,12 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
|||||||
"failed to parse IP %s", tt.ip,
|
"failed to parse IP %s", tt.ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
|
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
refused,
|
delivery.ExportIsBlockedIP(ip),
|
||||||
"default guard refuses %s = %v, want %v",
|
"isBlockedIP(%s) = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
refused,
|
delivery.ExportIsBlockedIP(ip),
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -101,42 +99,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
|
|
||||||
// covers the unspecified addresses and the IPv6 multicast and
|
|
||||||
// documentation ranges: with no allowlist set, each is refused
|
|
||||||
// both when a target is created and when a delivery dials it.
|
|
||||||
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
guard := delivery.NewTestGuard()
|
|
||||||
|
|
||||||
targets := []string{
|
|
||||||
// The unspecified addresses. On Linux a connection to
|
|
||||||
// either reaches this host's loopback.
|
|
||||||
"http://0.0.0.0:8080/hook",
|
|
||||||
"http://[::]:8080/hook",
|
|
||||||
// IPv6 multicast, all nodes.
|
|
||||||
"http://[ff02::1]/hook",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"http://[2001:db8::1]/hook",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range targets {
|
|
||||||
t.Run(target, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
guard.ValidateTargetURL(context.Background(), target),
|
|
||||||
"%s must be refused at target creation", target,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertDialRefused(t, guard, target)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateTargetURL_Allowed(t *testing.T) {
|
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -442,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
originScoped := applyRequestHeaders(
|
originScoped := applyRequestHeaders(req, event, cfg)
|
||||||
req, event, cfg, t.eng.userAgent(),
|
|
||||||
)
|
|
||||||
|
|
||||||
client := t.clientForRequest(cfg, originScoped)
|
client := t.clientForRequest(cfg, originScoped)
|
||||||
|
|
||||||
@@ -564,13 +562,10 @@ func isForwardableHeader(name string) bool {
|
|||||||
// Content-Type goes out once: a Content-Type configured on the target
|
// Content-Type goes out once: a Content-Type configured on the target
|
||||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||||
// Content-Type in the event's headers is never forwarded.
|
// Content-Type in the event's headers is never forwarded.
|
||||||
//
|
|
||||||
// userAgent is set last, over any configured or inbound User-Agent.
|
|
||||||
func applyRequestHeaders(
|
func applyRequestHeaders(
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
userAgent string,
|
|
||||||
) []string {
|
) []string {
|
||||||
if event.ContentType != "" {
|
if event.ContentType != "" {
|
||||||
req.Header.Set(
|
req.Header.Set(
|
||||||
@@ -585,7 +580,7 @@ func applyRequestHeaders(
|
|||||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
// A Content-Type configured on the target describes the body
|
// A Content-Type configured on the target describes the body
|
||||||
// being sent rather than the sender. A 307/308 preserves the
|
// being sent rather than the sender. A 307/308 preserves the
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", t.eng.userAgent())
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
resp, doErr := executeHTTPRequest(t.client, req)
|
resp, doErr := executeHTTPRequest(t.client, req)
|
||||||
durationMs := time.Since(start).Milliseconds()
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|||||||
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
|||||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
|
|
||||||
// that the failure of a retry abandoned because its target is gone is
|
|
||||||
// added to that target's own totals, not to a row with no target.
|
|
||||||
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
|
|
||||||
var hits atomic.Int64
|
|
||||||
|
|
||||||
task, targetID := tRetryChainSetup(
|
|
||||||
t, s, "gone-counted", &hits,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, s.MainDB.Delete(
|
|
||||||
&database.Target{}, "id = ?", targetID,
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
s.Engine.ExportProcessRetryTask(
|
|
||||||
context.Background(), &task,
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, s.WebhookDB.Find(&rows).Error)
|
|
||||||
assert.Equal(t, []database.TargetTotals{
|
|
||||||
{TargetID: targetID, Failed: 1},
|
|
||||||
}, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
||||||
// mutation check: a liveness check that refused every retry would pass
|
// mutation check: a liveness check that refused every retry would pass
|
||||||
// the test above and break every retry there is.
|
// the test above and break every retry there is.
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Both the http and the slack target send webhooker/ and the version
|
|
||||||
// in Globals, the value the web UI footer shows. A User-Agent
|
|
||||||
// configured on the target or carried in by the sender does not
|
|
||||||
// replace it.
|
|
||||||
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const want = "webhooker/1.2.3-test"
|
|
||||||
|
|
||||||
userAgents := make(chan string, 1)
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userAgents <- r.Header.Get("User-Agent")
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
g := &globals.Globals{Version: "1.2.3-test"}
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
e := delivery.New(lc, delivery.EngineParams{
|
|
||||||
Globals: g,
|
|
||||||
Logger: log,
|
|
||||||
// httptest listens on loopback, which the default guard
|
|
||||||
// refuses.
|
|
||||||
SSRFGuard: delivery.NewTestGuard(
|
|
||||||
netip.MustParsePrefix("127.0.0.0/8"),
|
|
||||||
),
|
|
||||||
Metrics: metrics.New(prometheus.NewRegistry()),
|
|
||||||
})
|
|
||||||
|
|
||||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
|
||||||
context.Background(),
|
|
||||||
&delivery.HTTPTargetConfig{
|
|
||||||
URL: ts.URL,
|
|
||||||
Headers: map[string]string{"User-Agent": "configured/1"},
|
|
||||||
},
|
|
||||||
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, statusCode)
|
|
||||||
require.Len(t, userAgents, 1, "the http target sent no request")
|
|
||||||
assert.Equal(t, want, <-userAgents, "http target")
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
slackCfg, err := json.Marshal(
|
|
||||||
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"action":"test"}`)
|
|
||||||
dlv := seedDelivery(
|
|
||||||
t, db, event.ID, targetID, database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
|
|
||||||
dlv, event, targetID, "test-slack", string(slackCfg),
|
|
||||||
))
|
|
||||||
require.Len(t, userAgents, 1, "the slack target sent no request")
|
|
||||||
assert.Equal(t, want, <-userAgents, "slack target")
|
|
||||||
}
|
|
||||||
@@ -137,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
+31
-62
@@ -2,56 +2,19 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// loginDestination returns where a successful login sends the
|
|
||||||
// browser: next when it is a path on this site, otherwise "/", which
|
|
||||||
// leads to the webhook list.
|
|
||||||
//
|
|
||||||
// A browser reads "//host" as another site, reads "\" as "/", and
|
|
||||||
// drops tabs and newlines before reading at all. So the value must
|
|
||||||
// start with exactly one "/" and hold no "\" or control character
|
|
||||||
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
|
|
||||||
// is checked after percent-decoding, so an encoded form of any of
|
|
||||||
// these is refused too.
|
|
||||||
func loginDestination(next string) string {
|
|
||||||
if len(next) > middleware.MaxNextBytes {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, err := url.PathUnescape(next)
|
|
||||||
if err != nil ||
|
|
||||||
!strings.HasPrefix(decoded, "/") ||
|
|
||||||
strings.HasPrefix(decoded, "//") ||
|
|
||||||
strings.Contains(decoded, `\`) ||
|
|
||||||
strings.ContainsFunc(decoded, unicode.IsControl) {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLoginPage returns a handler for the login page (GET)
|
// HandleLoginPage returns a handler for the login page (GET)
|
||||||
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
next := loginDestination(
|
|
||||||
r.URL.Query().Get(middleware.NextParam),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if already logged in
|
// Check if already logged in
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err == nil && h.session.IsAuthenticated(sess) {
|
if err == nil && h.session.IsAuthenticated(sess) {
|
||||||
http.Redirect( //nolint:gosec // checked by loginDestination
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
w, r, next, http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +22,6 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
// Render login page
|
// Render login page
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyError: "",
|
tmplKeyError: "",
|
||||||
tmplKeyNext: next,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "login.html", data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
@@ -74,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,13 +77,8 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
"user_id", user.ID,
|
"user_id", user.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The form value is the client's to set, so it is checked
|
// Redirect to home page
|
||||||
// again here rather than trusted from the rendered page.
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
http.Redirect( //nolint:gosec // checked by loginDestination
|
|
||||||
w, r,
|
|
||||||
loginDestination(r.PostFormValue(middleware.NextParam)),
|
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +91,6 @@ func (h *Handlers) renderLoginError(
|
|||||||
) {
|
) {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyError: msg,
|
tmplKeyError: msg,
|
||||||
tmplKeyNext: loginDestination(
|
|
||||||
r.PostFormValue(middleware.NextParam),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -149,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.
|
||||||
@@ -212,7 +165,11 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.log.Error("failed to verify password", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -284,14 +241,24 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.log.Error("failed to get session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to regenerate session", err)
|
h.log.Error(
|
||||||
|
"failed to regenerate session", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -300,7 +267,11 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to save session", err)
|
h.log.Error("failed to save session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -333,9 +304,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
// Redirect to login page
|
||||||
w, r, withNotice("/pages/login", signedOut),
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -454,202 +454,6 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
|
|
||||||
// returns to is client-chosen, so anything that is not a path on this
|
|
||||||
// site, plain or percent-encoded, must land on "/", the webhook list.
|
|
||||||
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
seedOperator(t, db)
|
|
||||||
|
|
||||||
cases := []struct{ next, want string }{
|
|
||||||
{"/hook/abc/events?page=2", "/hook/abc/events?page=2"},
|
|
||||||
{"", "/"},
|
|
||||||
{"https://evil.example/", "/"},
|
|
||||||
{"https%3A%2F%2Fevil.example%2F", "/"},
|
|
||||||
{"//evil.example/", "/"},
|
|
||||||
{"%2F%2Fevil.example/", "/"},
|
|
||||||
{"/%2Fevil.example/", "/"},
|
|
||||||
{`/\evil.example/`, "/"},
|
|
||||||
{"%2F%5Cevil.example/", "/"},
|
|
||||||
{"/%5Cevil.example/", "/"},
|
|
||||||
{`/a/../\evil.example/`, "/"},
|
|
||||||
{"/\t/evil.example/", "/"},
|
|
||||||
{"/%09/evil.example/", "/"},
|
|
||||||
{"/\n/evil.example/", "/"},
|
|
||||||
{"/%0A/evil.example/", "/"},
|
|
||||||
{"/\r/evil.example/", "/"},
|
|
||||||
{"/%0D/evil.example/", "/"},
|
|
||||||
{"/%00/evil.example/", "/"},
|
|
||||||
{"/%7F/evil.example/", "/"},
|
|
||||||
{"%252F%252Fevil.example/", "/"},
|
|
||||||
{"https%253A%252F%252Fevil.example%252F", "/"},
|
|
||||||
{"/" + strings.Repeat("a", 4096), "/"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", operatorUser)
|
|
||||||
form.Set("password", operatorPassword)
|
|
||||||
form.Set("next", c.next)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/pages/login",
|
|
||||||
strings.NewReader(form.Encode()),
|
|
||||||
)
|
|
||||||
req.Header.Set(
|
|
||||||
"Content-Type", "application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
req.RemoteAddr = sharedProxyPeer
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleLoginSubmit().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
|
||||||
assert.Equal(
|
|
||||||
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong
|
|
||||||
// password the login page is shown again with the same next, so the
|
|
||||||
// next attempt still returns to the page that was asked for.
|
|
||||||
func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
seedOperator(t, db)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", operatorUser)
|
|
||||||
form.Set("password", "wrong")
|
|
||||||
form.Set("next", "/hook/abc")
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/pages/login",
|
|
||||||
strings.NewReader(form.Encode()),
|
|
||||||
)
|
|
||||||
req.Header.Set(
|
|
||||||
"Content-Type", "application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
req.RemoteAddr = sharedProxyPeer
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleLoginSubmit().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), `name="next" value="/hook/abc"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginPageGet renders the login page as a GET with the given next
|
|
||||||
// value and cookies.
|
|
||||||
func loginPageGet(
|
|
||||||
h *handlers.Handlers, next string, cookies []*http.Cookie,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet,
|
|
||||||
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleLoginPage().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
|
|
||||||
// itself: its form carries the requested page only when it is a path
|
|
||||||
// on this site, and a browser already logged in goes straight there,
|
|
||||||
// or to "/" when it is not.
|
|
||||||
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, loginPageGet(h, "/hook/abc", nil).Body.String(),
|
|
||||||
`name="next" value="/hook/abc"`,
|
|
||||||
)
|
|
||||||
assert.Contains(
|
|
||||||
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
|
|
||||||
`name="next" value="/"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
|
|
||||||
|
|
||||||
cases := []struct{ next, want string }{
|
|
||||||
{"/hook/abc", "/hook/abc"},
|
|
||||||
{"//evil.example/", "/"},
|
|
||||||
{`/\evil.example/`, "/"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
w := loginPageGet(h, c.next, cookies)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
|
||||||
assert.Equal(
|
|
||||||
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
|
|
||||||
// page offers no link to the login page.
|
|
||||||
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
w := loginPageGet(h, "", nil)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
||||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
// database.MaxUsernameBytes still fits in the session cookie. Past
|
||||||
// what the cookie can carry, a correct login answers 500.
|
// what the cookie can carry, a correct login answers 500.
|
||||||
|
|||||||
@@ -11,37 +11,72 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The outcomes of a replay POST, as the notice codes its redirect
|
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||||
// carries. noticeFor holds the line each one shows.
|
// with and the event log page reads its banner from.
|
||||||
|
const replayOutcomeParam = "replay"
|
||||||
|
|
||||||
|
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||||
|
// carries one of these fixed codes rather than a message, so nothing a
|
||||||
|
// client submits can reach the rendered page through it.
|
||||||
|
type replayOutcomeCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// replayQueued reports that a new delivery was created and handed
|
// replayQueued reports that a new delivery was created and handed
|
||||||
// to the delivery engine.
|
// to the delivery engine.
|
||||||
replayQueued noticeCode = "replay-queued"
|
replayQueued replayOutcomeCode = "queued"
|
||||||
|
|
||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event.
|
// target and this is the ordinary case for an old event.
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
// all, deleted or otherwise.
|
// all, deleted or otherwise.
|
||||||
replayTargetMissing noticeCode = "replay-target-missing"
|
replayTargetMissing replayOutcomeCode = "target-missing"
|
||||||
|
|
||||||
// replayTargetInactive reports a target the operator has
|
// replayTargetInactive reports a target the operator has
|
||||||
// deactivated. A deactivated target receives no new deliveries, so
|
// deactivated. A deactivated target receives no new deliveries, so
|
||||||
// a replay to it would be a delivery they switched off.
|
// a replay to it would be a delivery they switched off.
|
||||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
replayTargetInactive replayOutcomeCode = "target-inactive"
|
||||||
|
|
||||||
// replayNotTerminal reports a delivery the engine has not finished
|
// replayNotTerminal reports a delivery the engine has not finished
|
||||||
// with.
|
// with.
|
||||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||||
|
|
||||||
// replayInFlight reports that an earlier replay of this event to
|
// replayInFlight reports that an earlier replay of this event to
|
||||||
// this target is still running.
|
// this target is still running.
|
||||||
replayInFlight noticeCode = "replay-in-flight"
|
replayInFlight replayOutcomeCode = "in-flight"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// replayOutcome returns the banner the event log page shows for an
|
||||||
|
// outcome code, and whether the replay was queued. An unrecognised
|
||||||
|
// code yields no banner.
|
||||||
|
func replayOutcome(code string) (string, bool) {
|
||||||
|
switch replayOutcomeCode(code) {
|
||||||
|
case replayQueued:
|
||||||
|
return "Replay queued: a new delivery was created against " +
|
||||||
|
"the target's current configuration.", true
|
||||||
|
case replayTargetDeleted:
|
||||||
|
return "Not replayed: the target this delivery was for has " +
|
||||||
|
"been deleted. Recreate the target, then replay.", false
|
||||||
|
case replayTargetMissing:
|
||||||
|
return "Not replayed: the target this delivery was for no " +
|
||||||
|
"longer exists.", false
|
||||||
|
case replayTargetInactive:
|
||||||
|
return "Not replayed: the target this delivery was for is " +
|
||||||
|
"deactivated. Activate it, then replay.", false
|
||||||
|
case replayNotTerminal:
|
||||||
|
return "Not replayed: this delivery has not finished yet.",
|
||||||
|
false
|
||||||
|
case replayInFlight:
|
||||||
|
return "Not replayed: a delivery of this event to this " +
|
||||||
|
"target is already in flight.", false
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||||
// target.
|
// target.
|
||||||
//
|
//
|
||||||
@@ -70,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,14 +124,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,14 +142,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !original.Status.Terminal() {
|
if !original.Status.Terminal() {
|
||||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||||
if target == nil {
|
if target == nil {
|
||||||
redirectToEventLog(w, r, webhook, code)
|
h.finishReplay(w, r, webhook, code)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,7 +173,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -158,14 +195,14 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count in-flight deliveries", err,
|
w, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if inFlight > 0 {
|
if inFlight > 0 {
|
||||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
h.finishReplay(w, r, webhook, replayInFlight)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,7 +212,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event for replay", err)
|
h.serverError(w, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -185,7 +222,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to create replay delivery", err,
|
w, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -203,7 +240,7 @@ func (h *Handlers) queueReplay(
|
|||||||
"delivery_id", task.DeliveryID,
|
"delivery_id", task.DeliveryID,
|
||||||
)
|
)
|
||||||
|
|
||||||
redirectToEventLog(w, r, webhook, replayQueued)
|
h.finishReplay(w, r, webhook, replayQueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayTarget loads the delivery's target as it stands now.
|
// replayTarget loads the delivery's target as it stands now.
|
||||||
@@ -216,7 +253,7 @@ func (h *Handlers) queueReplay(
|
|||||||
// with the returned code saying why.
|
// with the returned code saying why.
|
||||||
func (h *Handlers) replayTarget(
|
func (h *Handlers) replayTarget(
|
||||||
webhookID, targetID string,
|
webhookID, targetID string,
|
||||||
) (*database.Target, noticeCode) {
|
) (*database.Target, replayOutcomeCode) {
|
||||||
var target database.Target
|
var target database.Target
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().Where(
|
err := h.db.DB().Unscoped().Where(
|
||||||
@@ -262,9 +299,8 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row, adds it to
|
// createReplayDelivery writes the new pending delivery row and returns
|
||||||
// its target's totals in the same transaction, and returns the task
|
// the task that carries it to the delivery engine.
|
||||||
// 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
|
||||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||||
@@ -283,16 +319,7 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
||||||
err := tx.Omit(clause.Associations).Create(dlv).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: dlv.TargetID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
@@ -326,16 +353,17 @@ func replayBody(body string) *string {
|
|||||||
return &body
|
return &body
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// finishReplay redirects back to the event log the replay was
|
||||||
// log it was triggered from, carrying the outcome as its notice and
|
// triggered from, carrying the outcome code the page turns into a
|
||||||
// the page number the form submitted.
|
// banner and the page number the form submitted.
|
||||||
func redirectToEventLog(
|
func (h *Handlers) finishReplay(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code replayOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
|
replayOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
// this is a POST, and its query string is what logs and Referer
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.Contains(t, body, ">Replay<")
|
assert.Contains(t, body, ">Replay<")
|
||||||
|
|
||||||
refused := renderSourceLogsPageWithQuery(
|
refused := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
t, h, sess, wh.ID, "?replay=target-deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=made-up",
|
t, h, sess, wh.ID, "?replay=made-up",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NotContains(t, unknown, "alert-error")
|
assert.NotContains(t, unknown, "alert-error")
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
|
||||||
// which cannot render answers with the status it was reporting, as
|
|
||||||
// plain text, and is not attempted again: a page whose own render
|
|
||||||
// fails reaches the error page, and the error page failing as well
|
|
||||||
// ends there with the 500.
|
|
||||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// .Status is an int, so asking it for a field fails the render.
|
|
||||||
failing := `{{.Status.Missing}}`
|
|
||||||
h.AddTemplateForTest("error.html", template.Must(
|
|
||||||
template.New("error").Parse(failing),
|
|
||||||
))
|
|
||||||
h.AddTemplateForTest("failing.html", template.Must(
|
|
||||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to read event body", err)
|
h.serverError(w, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -10,19 +11,43 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The outcomes of a resubmit POST, as the notice codes its redirect
|
// resubmitOutcomeParam is the query parameter the resubmit POST
|
||||||
// carries. noticeFor holds the line each one shows.
|
// redirects with and the event log page reads its banner from.
|
||||||
|
const resubmitOutcomeParam = "resubmit"
|
||||||
|
|
||||||
|
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
||||||
|
// carries one of these fixed codes rather than a message, so nothing a
|
||||||
|
// client submits can reach the rendered page through it.
|
||||||
|
type resubmitOutcomeCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// resubmitQueued reports that a new event was stored and its
|
// resubmitQueued reports that a new event was stored and its
|
||||||
// deliveries handed to the delivery engine.
|
// deliveries handed to the delivery engine.
|
||||||
resubmitQueued noticeCode = "resubmit-queued"
|
resubmitQueued resubmitOutcomeCode = "queued"
|
||||||
|
|
||||||
// resubmitNoTargets reports a source with no active targets. The
|
// resubmitNoTargets reports a source with no active targets. The
|
||||||
// new event is stored either way, exactly as a received event
|
// new event is stored either way, exactly as a received event
|
||||||
// with no targets is.
|
// with no targets is.
|
||||||
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resubmitOutcome returns the banner the event log page shows for an
|
||||||
|
// outcome code, and whether the resubmit was queued. An unrecognised
|
||||||
|
// code yields no banner.
|
||||||
|
func resubmitOutcome(code string) (string, bool) {
|
||||||
|
switch resubmitOutcomeCode(code) {
|
||||||
|
case resubmitQueued:
|
||||||
|
return "Resubmitted: a new event was created from the stored " +
|
||||||
|
"one and queued to every active target.", true
|
||||||
|
case resubmitNoTargets:
|
||||||
|
return "Resubmitted: a new event was created, but this " +
|
||||||
|
"source has no active targets, so nothing was queued.",
|
||||||
|
true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||||
// read as bytes rather than as a string so the copy is byte-identical
|
// read as bytes rather than as a string so the copy is byte-identical
|
||||||
// to what was received, whatever the payload's encoding.
|
// to what was received, whatever the payload's encoding.
|
||||||
@@ -74,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event to resubmit", err)
|
h.serverError(w, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -182,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to store resubmitted event", err)
|
h.serverError(w, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,5 +245,29 @@ func (h *Handlers) queueResubmit(
|
|||||||
code = resubmitNoTargets
|
code = resubmitNoTargets
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToEventLog(w, r, webhook, code)
|
h.finishResubmit(w, r, webhook, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishResubmit redirects back to the event log the resubmit was
|
||||||
|
// triggered from, carrying the outcome code the page turns into a
|
||||||
|
// banner and the page number the form submitted.
|
||||||
|
func (h *Handlers) finishResubmit(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
code resubmitOutcomeCode,
|
||||||
|
) {
|
||||||
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
|
resubmitOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
|
// The page is read from the form rather than the query string:
|
||||||
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
// headers record.
|
||||||
|
if page := pageOrFirst(
|
||||||
|
r.PostFormValue("page"),
|
||||||
|
); page > 1 {
|
||||||
|
dest += "&page=" + strconv.Itoa(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -282,7 +281,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -436,7 +435,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -482,7 +481,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,43 +63,12 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, page,
|
w, webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func FinishedByTargetForTest(
|
|
||||||
webhookDB *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
return finishedByTarget(webhookDB, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRequestForTest is the request the helpers here pass on for
|
|
||||||
// callers that have none: it is used only to render the error page.
|
|
||||||
func newRequestForTest() *http.Request {
|
|
||||||
return httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -157,5 +122,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
return s.buildDatabaseTargetConfig(w, expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-122
@@ -12,9 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -30,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
|
||||||
|
|
||||||
@@ -38,9 +36,6 @@ const (
|
|||||||
tmplKeyError = "Error"
|
tmplKeyError = "Error"
|
||||||
// tmplKeyWebhook is the template data key for a webhook.
|
// tmplKeyWebhook is the template data key for a webhook.
|
||||||
tmplKeyWebhook = "Webhook"
|
tmplKeyWebhook = "Webhook"
|
||||||
// tmplKeyNext is the template data key for the page to return
|
|
||||||
// to after login.
|
|
||||||
tmplKeyNext = "Next"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errInvalidPassword is returned when a password does not match.
|
// errInvalidPassword is returned when a password does not match.
|
||||||
@@ -58,7 +53,6 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -67,8 +61,6 @@ type HandlersParams struct {
|
|||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Evictor delivery.WebhookEvictor
|
Evictor delivery.WebhookEvictor
|
||||||
SSRFGuard *delivery.Guard
|
SSRFGuard *delivery.Guard
|
||||||
Metrics *metrics.Set
|
|
||||||
Registry *prometheus.Registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -99,23 +91,18 @@ 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, navbar and notice templates, and with any further
|
// base, htmlheader, and navbar templates. The page file must be
|
||||||
// files the page includes. The page file must be listed first so that
|
// listed first so that its root action ({{template "base" .}})
|
||||||
// its root action ({{template "base" .}}) becomes the template set's
|
// becomes the template set's entry point.
|
||||||
// entry point.
|
func parsePageTemplate(pageFile string) *template.Template {
|
||||||
func parsePageTemplate(
|
return template.Must(
|
||||||
pageFile string, included ...string,
|
template.ParseFS(
|
||||||
) *template.Template {
|
templates.Templates,
|
||||||
files := append([]string{
|
|
||||||
pageFile,
|
pageFile,
|
||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
"notice.html",
|
),
|
||||||
}, included...)
|
|
||||||
|
|
||||||
return template.Must(
|
|
||||||
template.ParseFS(templates.Templates, files...),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,21 +122,19 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.evictor = params.Evictor
|
s.evictor = params.Evictor
|
||||||
s.mtr = params.Metrics
|
s.mtr = metrics.Default()
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"settings.html": parsePageTemplate("settings.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", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.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"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -161,16 +146,6 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleErrorPage returns a handler that answers every request with
|
|
||||||
// the error page for status. The router uses it for unknown paths, the
|
|
||||||
// CSRF middleware for a refused form, and each admin page route
|
|
||||||
// group's recoverer for a panic.
|
|
||||||
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderError(w, r, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -188,78 +163,15 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and answers with the 500 error page.
|
// serverError logs an error and sends a 500 response.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
w http.ResponseWriter, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
}
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
// renderError answers with status and the error page: the normal
|
)
|
||||||
// layout, one fixed line explaining the status, and a link back to the
|
|
||||||
// webhook list, or to sign-in when nobody is signed in.
|
|
||||||
//
|
|
||||||
// It renders the page itself rather than through renderTemplate,
|
|
||||||
// whose own failure comes here. If the error page cannot render
|
|
||||||
// either, the answer is the same status in plain text: never a second
|
|
||||||
// attempt, and never a different status.
|
|
||||||
func (s *Handlers) renderError(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
status int,
|
|
||||||
) {
|
|
||||||
// The page names the signed-in user, and some error pages are
|
|
||||||
// served outside the routes where NoCache runs.
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
|
|
||||||
// No notice: one would say an action worked above a page saying
|
|
||||||
// the request failed.
|
|
||||||
data := s.pageData(r, map[string]any{
|
|
||||||
"Status": status,
|
|
||||||
"StatusText": http.StatusText(status),
|
|
||||||
"Message": errorPageText(status),
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
err := s.templates["error.html"].Execute(&buf, data)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to render error page", "error", err)
|
|
||||||
http.Error(w, http.StatusText(status), status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
|
|
||||||
_, err = buf.WriteTo(w)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write error page", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errorPageText is the line the error page shows for status. It is
|
|
||||||
// fixed per status, so the page tells the reader no more than the
|
|
||||||
// plain-text answers it replaced did.
|
|
||||||
func errorPageText(status int) string {
|
|
||||||
switch status {
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
return "The request could not be read."
|
|
||||||
case http.StatusForbidden:
|
|
||||||
return "The request was refused. If it came from a form " +
|
|
||||||
"left open for a long time, reload the page and try " +
|
|
||||||
"again."
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return "There is nothing here. It may have been deleted, " +
|
|
||||||
"or the address may be wrong."
|
|
||||||
case http.StatusServiceUnavailable:
|
|
||||||
return "The server is busy. Please try again in a moment."
|
|
||||||
default: // http.StatusInternalServerError
|
|
||||||
return "Something went wrong on the server. Please try " +
|
|
||||||
"again."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -273,7 +185,6 @@ type templateDataWrapper struct {
|
|||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Version string
|
Version string
|
||||||
Notice *notice
|
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,20 +224,14 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageData adds the fields the shared layout renders to a page's own
|
|
||||||
// data. The layout shows the notice, when there is one, above the
|
|
||||||
// page.
|
|
||||||
func (s *Handlers) pageData(
|
|
||||||
r *http.Request, data any, pageNotice *notice,
|
|
||||||
) any {
|
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -340,18 +245,19 @@ func (s *Handlers) pageData(
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
m["Notice"] = pageNotice
|
s.executeTemplate(w, tmpl, m)
|
||||||
|
|
||||||
return m
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Notice: pageNotice,
|
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(w, tmpl, wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
@@ -364,7 +270,6 @@ func (s *Handlers) pageData(
|
|||||||
// this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
) {
|
) {
|
||||||
@@ -375,7 +280,10 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
@@ -84,29 +83,16 @@ func newTestApp(
|
|||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newTestAppWithConfig(
|
|
||||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
|
||||||
func newTestAppWithConfig(
|
|
||||||
t *testing.T,
|
|
||||||
cfg *config.Config,
|
|
||||||
targets ...any,
|
|
||||||
) *fxtest.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config { return cfg },
|
func() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
},
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
@@ -123,8 +109,6 @@ func newTestAppWithConfig(
|
|||||||
func(r *recordingEvictor) delivery.WebhookEvictor {
|
func(r *recordingEvictor) delivery.WebhookEvictor {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -323,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.NotContains(
|
assert.Equal(
|
||||||
t, w.Body.String(), partialPageMarker,
|
t, "Internal server error\n", w.Body.String(),
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "500 Internal Server Error",
|
|
||||||
"a failed render must answer with the error page",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleMetrics returns the Prometheus scrape handler for the
|
|
||||||
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
|
|
||||||
// runtime and process collectors register on. It is what
|
|
||||||
// promhttp.Handler builds for the global default registry, including
|
|
||||||
// the promhttp_metric_handler_* series that count scrapes, pointed at
|
|
||||||
// that registry instead.
|
|
||||||
func (s *Handlers) HandleMetrics() http.HandlerFunc {
|
|
||||||
reg := s.params.Registry
|
|
||||||
|
|
||||||
return promhttp.InstrumentMetricHandler(
|
|
||||||
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
|
|
||||||
).ServeHTTP
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// noticeParam is the query parameter an action's redirect carries its
|
|
||||||
// notice code in.
|
|
||||||
const noticeParam = "notice"
|
|
||||||
|
|
||||||
// noticeCode names one of the fixed lines noticeFor knows. An action
|
|
||||||
// redirects with the code rather than the line, so nothing a client
|
|
||||||
// puts in the URL reaches the page: a code noticeFor does not know
|
|
||||||
// shows nothing.
|
|
||||||
type noticeCode string
|
|
||||||
|
|
||||||
// The codes of the actions on the webhook pages and of signing out.
|
|
||||||
// Replay's codes, with the reasons a replay can be refused, and
|
|
||||||
// resubmit's codes are defined beside those actions.
|
|
||||||
const (
|
|
||||||
webhookCreated noticeCode = "webhook-created"
|
|
||||||
webhookSaved noticeCode = "webhook-saved"
|
|
||||||
webhookDeleted noticeCode = "webhook-deleted"
|
|
||||||
entrypointAdded noticeCode = "entrypoint-added"
|
|
||||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
|
||||||
entrypointActivated noticeCode = "entrypoint-activated"
|
|
||||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
|
||||||
targetAdded noticeCode = "target-added"
|
|
||||||
targetSaved noticeCode = "target-saved"
|
|
||||||
targetDeleted noticeCode = "target-deleted"
|
|
||||||
targetActivated noticeCode = "target-activated"
|
|
||||||
targetDeactivated noticeCode = "target-deactivated"
|
|
||||||
signedOut noticeCode = "signed-out"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notice is the line templates/notice.html shows above a page to say
|
|
||||||
// what an action did.
|
|
||||||
type notice struct {
|
|
||||||
Text string
|
|
||||||
|
|
||||||
// Failed shows the line as an error: the action was refused.
|
|
||||||
Failed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// noticeFor returns the notice the request's URL names, or nil when it
|
|
||||||
// names none or an unknown code.
|
|
||||||
func noticeFor(r *http.Request) *notice {
|
|
||||||
n, ok := map[noticeCode]notice{
|
|
||||||
webhookCreated: {Text: "Webhook created."},
|
|
||||||
webhookSaved: {Text: "Webhook saved."},
|
|
||||||
webhookDeleted: {Text: "Webhook deleted."},
|
|
||||||
entrypointAdded: {Text: "Entrypoint added."},
|
|
||||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
|
||||||
entrypointActivated: {Text: "Entrypoint activated."},
|
|
||||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
|
||||||
targetAdded: {Text: "Target added."},
|
|
||||||
targetSaved: {Text: "Target saved."},
|
|
||||||
targetDeleted: {Text: "Target deleted."},
|
|
||||||
targetActivated: {Text: "Target activated."},
|
|
||||||
targetDeactivated: {Text: "Target deactivated."},
|
|
||||||
signedOut: {Text: "Signed out."},
|
|
||||||
|
|
||||||
replayQueued: {
|
|
||||||
Text: "Replay queued: a new delivery was created " +
|
|
||||||
"against the target's current configuration.",
|
|
||||||
},
|
|
||||||
replayTargetDeleted: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"has been deleted. Recreate the target, then replay.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayTargetMissing: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"no longer exists.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayTargetInactive: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"is deactivated. Activate it, then replay.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayNotTerminal: {
|
|
||||||
Text: "Not replayed: this delivery has not finished yet.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayInFlight: {
|
|
||||||
Text: "Not replayed: a delivery of this event to this " +
|
|
||||||
"target is already in flight.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
resubmitQueued: {
|
|
||||||
Text: "Resubmitted: a new event was created from the " +
|
|
||||||
"stored one and queued to every active target.",
|
|
||||||
},
|
|
||||||
resubmitNoTargets: {
|
|
||||||
Text: "Resubmitted: a new event was created, but this " +
|
|
||||||
"source has no active targets, so nothing was queued.",
|
|
||||||
},
|
|
||||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &n
|
|
||||||
}
|
|
||||||
|
|
||||||
// withNotice returns path with code added as its notice.
|
|
||||||
func withNotice(path string, code noticeCode) string {
|
|
||||||
return path + "?" + noticeParam + "=" + string(code)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
|
r.Context(),
|
||||||
w,
|
w,
|
||||||
r,
|
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes the
|
// to display on the profile page. On an internal failure it writes a
|
||||||
// error page itself and returns handled=false, signalling the caller
|
// 500 response itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
|
ctx context.Context,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
release, ok := h.mw.BeginPasswordVerification(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
h.renderError(w, r, http.StatusServiceUnavailable)
|
http.Error(
|
||||||
|
w,
|
||||||
|
"The server is busy verifying credentials. "+
|
||||||
|
"Please try again.",
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load user for password change", err,
|
w, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.serverError(w, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to hash new password", err)
|
h.serverError(w, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update password", err)
|
h.serverError(w, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.serverError(w, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
h.renderError(w, r, http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -128,9 +126,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
var h *handlers.Handlers
|
app := newTestApp(t, &log, &cfg, &sess)
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess, &h)
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -141,7 +137,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(mw.CSRF())
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
@@ -162,10 +158,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
"handler must not be reached for unauthenticated request",
|
"handler must not be reached for unauthenticated request",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
t, "/pages/login?next=%2Fuser%2Ftestuser",
|
|
||||||
w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// passwordChangeRequest builds a POST request to the password-change
|
// passwordChangeRequest builds a POST request to the password-change
|
||||||
|
|||||||
@@ -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, "/h/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.")
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notSet is what the Settings page shows for a value that is empty.
|
|
||||||
const notSet = "not set"
|
|
||||||
|
|
||||||
// settingRow is one line of the Settings page: an environment
|
|
||||||
// variable, what it controls, and the value the server loaded for it.
|
|
||||||
type settingRow struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSettings returns a handler for the read-only Settings page,
|
|
||||||
// which lists the configuration the server started with.
|
|
||||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
|
||||||
"Settings": settingRows(h.params.Config),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingRows lists every field of cfg under the environment variable
|
|
||||||
// it is read from, with the description the README's configuration
|
|
||||||
// table gives it (less its pointers to other README sections), in the
|
|
||||||
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
|
|
||||||
// their values never reach the page: only whether they are set.
|
|
||||||
func settingRows(cfg *config.Config) []settingRow {
|
|
||||||
metricsUsername := cfg.MetricsUsername
|
|
||||||
if metricsUsername == "" {
|
|
||||||
metricsUsername = notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return []settingRow{
|
|
||||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
|
||||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
|
||||||
{
|
|
||||||
"BIND_ADDRESS",
|
|
||||||
"IP address the HTTP listener binds. Loopback by default, " +
|
|
||||||
"so the cleartext listener is not published on every " +
|
|
||||||
"interface. The Docker image ships 0.0.0.0 instead",
|
|
||||||
cfg.BindAddress,
|
|
||||||
},
|
|
||||||
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
|
|
||||||
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
|
|
||||||
{
|
|
||||||
"MAINTENANCE_MODE",
|
|
||||||
"Report maintenanceMode: true in the healthcheck JSON. " +
|
|
||||||
"It does not change how any request is served — no " +
|
|
||||||
"maintenance page exists",
|
|
||||||
strconv.FormatBool(cfg.MaintenanceMode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_USERNAME",
|
|
||||||
"Basic auth username for /metrics. Must be set together " +
|
|
||||||
"with METRICS_PASSWORD; one without the other fails " +
|
|
||||||
"startup",
|
|
||||||
metricsUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_PASSWORD",
|
|
||||||
"Basic auth password for /metrics. Must be set together " +
|
|
||||||
"with METRICS_USERNAME; one without the other fails " +
|
|
||||||
"startup",
|
|
||||||
setOrNotSet(cfg.MetricsPassword),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SENTRY_DSN",
|
|
||||||
"Sentry error reporting DSN. Unset leaves error reporting " +
|
|
||||||
"off; a value the Sentry SDK cannot parse fails startup " +
|
|
||||||
"rather than serving with reporting silently off",
|
|
||||||
setOrNotSet(cfg.SentryDSN),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
|
||||||
"How often the retention reaper and archive sweeper run " +
|
|
||||||
"(Go duration, must be positive)",
|
|
||||||
cfg.RetentionSweepInterval.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SESSION_IDLE_TIMEOUT",
|
|
||||||
"Idle session timeout (Go duration)",
|
|
||||||
cfg.SessionIdleTimeout.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RECEIVER_RATE_LIMIT",
|
|
||||||
"Receiver requests/minute per IP per entrypoint " +
|
|
||||||
"(10x that per IP across the route)",
|
|
||||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
cidrList(cfg.TrustedProxies),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ALLOWED_EGRESS_CIDRS",
|
|
||||||
"CIDRs that delivery targets may reach despite the " +
|
|
||||||
"SSRF blocklist",
|
|
||||||
cidrList(cfg.AllowedEgressCIDRs),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
|
||||||
// has a value, never the value itself.
|
|
||||||
func setOrNotSet(value string) string {
|
|
||||||
if value == "" {
|
|
||||||
return notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return "set"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cidrList renders a CIDR list setting for the Settings page.
|
|
||||||
func cidrList(prefixes []netip.Prefix) string {
|
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
|
||||||
// and returns the value it shows for each variable name, plus the
|
|
||||||
// whole page.
|
|
||||||
func settingsShown(
|
|
||||||
t *testing.T, cfg *config.Config,
|
|
||||||
) (map[string]string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/settings", nil,
|
|
||||||
)
|
|
||||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleSettings().ServeHTTP(w, req)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
row := regexp.MustCompile(
|
|
||||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
shown := map[string]string{}
|
|
||||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
|
||||||
shown[match[1]] = html.UnescapeString(match[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
return shown, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
|
|
||||||
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
|
|
||||||
// of the three set in one of the content tests, so each row is
|
|
||||||
// checked against its own field.
|
|
||||||
cfg := &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Debug: true,
|
|
||||||
MaintenanceMode: false,
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
MetricsUsername: "scraper",
|
|
||||||
MetricsPassword: "",
|
|
||||||
Port: 9123,
|
|
||||||
SentryDSN: "",
|
|
||||||
BindAddress: "192.0.2.10",
|
|
||||||
RetentionSweepInterval: 17 * time.Minute,
|
|
||||||
SessionIdleTimeout: 3 * time.Hour,
|
|
||||||
ReceiverRateLimit: 77,
|
|
||||||
TrustedProxies: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.1.0.0/16"),
|
|
||||||
},
|
|
||||||
AllowedEgressCIDRs: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("192.168.5.0/24"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, cfg)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
|
||||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
|
||||||
"PORT": "9123",
|
|
||||||
"BIND_ADDRESS": "192.0.2.10",
|
|
||||||
"DATA_DIR": cfg.DataDir,
|
|
||||||
"DEBUG": "true",
|
|
||||||
"MAINTENANCE_MODE": "false",
|
|
||||||
"METRICS_USERNAME": "scraper",
|
|
||||||
"METRICS_PASSWORD": "not set",
|
|
||||||
"SENTRY_DSN": "not set",
|
|
||||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
|
||||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
|
||||||
"RECEIVER_RATE_LIMIT": "77",
|
|
||||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
|
||||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
|
||||||
}, shown)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/settings"`,
|
|
||||||
"the navigation bar links to the page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const metricsPassword = "metrics-password-1f9a"
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
MetricsPassword: metricsPassword,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, metricsPassword)
|
|
||||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
|
||||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
sentryKey = "dsnkey7c2e"
|
|
||||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
|
||||||
)
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
SentryDSN: sentryDSN,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, sentryKey)
|
|
||||||
}
|
|
||||||
@@ -411,9 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -241,37 +226,3 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
assert.Contains(t, body, "(unavailable)")
|
assert.Contains(t, body, "(unavailable)")
|
||||||
assert.NotContains(t, body, "beak")
|
assert.NotContains(t, body, "beak")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
|
|
||||||
// page's maximum width at 108rem (1728 px), half again the 72rem of
|
|
||||||
// max-w-6xl that the webhook list and the event log use, so an
|
|
||||||
// entrypoint URL fits on one line in a 1920-pixel window; and the
|
|
||||||
// wrapping of its title row, so the buttons beside the title do not
|
|
||||||
// push a phone-width window into scrolling sideways.
|
|
||||||
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body,
|
|
||||||
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
|
||||||
)
|
|
||||||
assert.Contains(
|
|
||||||
t, body,
|
|
||||||
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
"user_id = ?", userID,
|
"user_id = ?", userID,
|
||||||
).Order("created_at DESC").Find(&webhooks).Error
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to list webhooks", err)
|
h.log.Error(
|
||||||
|
"failed to list webhooks", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
err := h.commitWebhook(webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create webhook", err)
|
h.serverError(w, "failed to create webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -322,8 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -408,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, r, "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, r, "failed to load recent events", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +450,6 @@ 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)
|
||||||
@@ -476,7 +475,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -511,7 +510,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -520,7 +519,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -574,14 +575,13 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
err := h.db.DB().Save(webhook).Error
|
err := h.db.DB().Save(webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update webhook", err)
|
h.serverError(w, "failed to update webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +605,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -632,7 +632,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// be removed by hand; deleted history cannot be recovered.
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
err := h.commitWebhookDeletion(&webhook)
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to delete webhook", err)
|
h.serverError(w, "failed to delete webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -658,15 +658,13 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// redirecting as though everything succeeded: the file
|
// redirecting as though everything succeeded: the file
|
||||||
// needs removing by hand, and the logged error names it.
|
// needs removing by hand, and the logged error names it.
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to delete webhook event database", err,
|
w, "failed to delete webhook event database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
@@ -804,7 +802,7 @@ func (h *Handlers) ownedWebhook(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, false
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
@@ -826,7 +824,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// Without the map every delivery renders through a
|
// Without the map every delivery renders through a
|
||||||
// zero redactor, so failing the page is the only
|
// zero redactor, so failing the page is the only
|
||||||
// safe answer.
|
// safe answer.
|
||||||
h.serverError(w, r, "failed to load targets", err)
|
h.serverError(w, "failed to load targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -834,7 +832,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
page := h.parsePage(r)
|
page := h.parsePage(r)
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, r, webhook, targets, page,
|
w, webhook, targets, page,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -845,9 +843,24 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
totalPages++
|
totalPages++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The banner a replay or resubmit POST redirected back
|
||||||
|
// with. The message comes from a fixed set keyed by the
|
||||||
|
// outcome code, never from the query string itself.
|
||||||
|
replayMsg, replayOK := replayOutcome(
|
||||||
|
r.URL.Query().Get(replayOutcomeParam),
|
||||||
|
)
|
||||||
|
|
||||||
|
resubmitMsg, resubmitOK := resubmitOutcome(
|
||||||
|
r.URL.Query().Get(resubmitOutcomeParam),
|
||||||
|
)
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
|
"ReplayMessage": replayMsg,
|
||||||
|
"ReplayQueued": replayOK,
|
||||||
|
"ResubmitMessage": resubmitMsg,
|
||||||
|
"ResubmitQueued": resubmitOK,
|
||||||
"Page": page,
|
"Page": page,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
"TotalEvents": total,
|
"TotalEvents": total,
|
||||||
@@ -929,7 +942,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
// caller must then render nothing further.
|
// caller must then render nothing further.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
@@ -943,7 +955,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to get webhook database", err,
|
w, "failed to get webhook database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -980,7 +992,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load delivery attempts", err,
|
w, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -989,7 +1001,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count event resubmissions", err,
|
w, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1212,7 +1224,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1221,7 +1233,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1237,14 +1251,13 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create entrypoint", err)
|
h.serverError(w, "failed to create entrypoint", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1269,7 +1282,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1291,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1349,14 +1364,13 @@ func (h *Handlers) processTargetCreate(
|
|||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create target", err)
|
h.serverError(w, "failed to create target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1458,7 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1494,7 +1508,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -1516,7 +1530,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1556,23 +1570,11 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"url", delivery.MaskURL(targetURL),
|
"url", delivery.MaskURL(targetURL),
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
http.Error(
|
||||||
msg := "Invalid target URL: " + err.Error()
|
w,
|
||||||
|
"Invalid target URL: "+err.Error(),
|
||||||
// Only a private or reserved address's refusal says how
|
http.StatusBadRequest,
|
||||||
// to allow it. Other refusals never do: link-local, the
|
)
|
||||||
// unspecified addresses and the unconditional metadata
|
|
||||||
// addresses cannot be opened, and the default
|
|
||||||
// blocklist's public addresses, which listing does open,
|
|
||||||
// hand out credentials.
|
|
||||||
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
|
||||||
msg += ". Private and reserved addresses are refused " +
|
|
||||||
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
|
||||||
"setting allows named networks (see \"Allowing " +
|
|
||||||
"egress to your own network\" in the README)."
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(w, msg, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1582,14 +1584,16 @@ func (h *Handlers) validateTargetURL(
|
|||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage,
|
||||||
// writing a 500 itself if it cannot.
|
// writing a 500 itself if it cannot.
|
||||||
func (h *Handlers) marshalTargetConfig(
|
func marshalTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
cfg any,
|
cfg any,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1605,7 +1609,6 @@ func (h *Handlers) marshalTargetConfig(
|
|||||||
// expiry yields an empty config (the keep-forever default).
|
// expiry yields an empty config (the keep-forever default).
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
func (h *Handlers) buildDatabaseTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
@@ -1624,8 +1627,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(
|
return marshalTargetConfig(
|
||||||
w, r, map[string]any{"expiry": expiry},
|
w, map[string]any{"expiry": expiry},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,7 +1638,6 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|||||||
"entrypointID", &database.Entrypoint{},
|
"entrypointID", &database.Entrypoint{},
|
||||||
"failed to delete entrypoint",
|
"failed to delete entrypoint",
|
||||||
nil,
|
nil,
|
||||||
entrypointDeleted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1648,21 +1650,18 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
|||||||
"targetID", &database.Target{},
|
"targetID", &database.Target{},
|
||||||
"failed to delete target",
|
"failed to delete target",
|
||||||
h.evictArchiveWriterIfUnused,
|
h.evictArchiveWriterIfUnused,
|
||||||
targetDeleted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteChildResource returns a handler that deletes a child
|
// deleteChildResource returns a handler that deletes a child
|
||||||
// resource (entrypoint or target) belonging to a webhook. The
|
// resource (entrypoint or target) belonging to a webhook. The
|
||||||
// optional afterDelete hook runs with the webhook's id once the
|
// optional afterDelete hook runs with the webhook's id once the
|
||||||
// delete has succeeded, before the redirect, which carries done as
|
// delete has succeeded, before the redirect.
|
||||||
// its notice.
|
|
||||||
func (h *Handlers) deleteChildResource(
|
func (h *Handlers) deleteChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
model any,
|
model any,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
afterDelete func(webhookID string),
|
afterDelete func(webhookID string),
|
||||||
done noticeCode,
|
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1683,7 +1682,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1693,7 +1692,11 @@ func (h *Handlers) deleteChildResource(
|
|||||||
childID, webhook.ID,
|
childID, webhook.ID,
|
||||||
).Delete(model)
|
).Delete(model)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
h.serverError(w, r, errMsg, result.Error)
|
h.log.Error(errMsg, "error", result.Error)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1704,7 +1707,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
withNotice("/hook/"+webhook.ID, done),
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1715,7 +1718,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"entrypointID",
|
"entrypointID",
|
||||||
func(webhookID, childID string) (bool, error) {
|
func(webhookID, childID string) error {
|
||||||
var ep database.Entrypoint
|
var ep database.Entrypoint
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1723,15 +1726,14 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&ep).Error
|
).First(&ep).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ep.Active = !ep.Active
|
ep.Active = !ep.Active
|
||||||
|
|
||||||
return ep.Active, h.db.DB().Save(&ep).Error
|
return h.db.DB().Save(&ep).Error
|
||||||
},
|
},
|
||||||
"failed to toggle entrypoint",
|
"failed to toggle entrypoint",
|
||||||
entrypointActivated, entrypointDeactivated,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1739,7 +1741,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"targetID",
|
"targetID",
|
||||||
func(webhookID, childID string) (bool, error) {
|
func(webhookID, childID string) error {
|
||||||
var tgt database.Target
|
var tgt database.Target
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1747,27 +1749,23 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&tgt).Error
|
).First(&tgt).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tgt.Active = !tgt.Active
|
tgt.Active = !tgt.Active
|
||||||
|
|
||||||
return tgt.Active, h.db.DB().Save(&tgt).Error
|
return h.db.DB().Save(&tgt).Error
|
||||||
},
|
},
|
||||||
"failed to toggle target",
|
"failed to toggle target",
|
||||||
targetActivated, targetDeactivated,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleChildResource returns a handler that toggles the active
|
// toggleChildResource returns a handler that toggles the active
|
||||||
// state of a child resource belonging to a webhook. toggleFn returns
|
// state of a child resource belonging to a webhook.
|
||||||
// the new state, and the redirect carries activated or deactivated as
|
|
||||||
// its notice to match.
|
|
||||||
func (h *Handlers) toggleChildResource(
|
func (h *Handlers) toggleChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
toggleFn func(webhookID, childID string) (bool, error),
|
toggleFn func(webhookID, childID string) error,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
activated, deactivated noticeCode,
|
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1788,26 +1786,25 @@ func (h *Handlers) toggleChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
active, err := toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, errMsg, err)
|
h.log.Error(errMsg, "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
done := deactivated
|
|
||||||
if active {
|
|
||||||
done = activated
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
withNotice("/hook/"+webhook.ID, done),
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,14 +157,13 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
err = h.db.DB().Save(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update target", err)
|
h.serverError(w, "failed to update target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ func (h *Handlers) ownedTarget(
|
|||||||
chi.URLParam(r, "targetID"), webhook.ID,
|
chi.URLParam(r, "targetID"), webhook.ID,
|
||||||
).First(&target).Error
|
).First(&target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, nil, false
|
return database.Webhook{}, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// privateRefusalHint is the sentence that tells an operator a private
|
|
||||||
// destination is refused on purpose, and how to allow one.
|
|
||||||
const privateRefusalHint = "Private and reserved addresses are " +
|
|
||||||
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
|
|
||||||
"allows named networks (see \"Allowing egress to your own " +
|
|
||||||
"network\" in the README)."
|
|
||||||
|
|
||||||
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
|
|
||||||
// target types that take a URL, on add and on edit.
|
|
||||||
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
targetTypes := []database.TargetType{
|
|
||||||
database.TargetTypeHTTP,
|
|
||||||
database.TargetTypeSlack,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, targetType := range targetTypes {
|
|
||||||
t.Run(string(targetType), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
targetsPath := "/hook/" + webhook.ID + "/targets"
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "private")
|
|
||||||
form.Set("type", string(targetType))
|
|
||||||
form.Set("url", editBlockedURL)
|
|
||||||
|
|
||||||
added := serveTarget(
|
|
||||||
env, http.MethodPost, targetsPath, form,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, added.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, added.Body.String(), privateRefusalHint,
|
|
||||||
)
|
|
||||||
|
|
||||||
form.Set("url", editOriginalURL)
|
|
||||||
|
|
||||||
created := serveTarget(
|
|
||||||
env, http.MethodPost, targetsPath, form,
|
|
||||||
)
|
|
||||||
require.Equal(
|
|
||||||
t, http.StatusSeeOther, created.Code,
|
|
||||||
created.Body.String(),
|
|
||||||
)
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
|
|
||||||
form.Set("url", editBlockedURL)
|
|
||||||
|
|
||||||
edited := submitTargetEdit(
|
|
||||||
env, webhook.ID, targets[0].ID, form,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, edited.Body.String(), privateRefusalHint,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
|
|
||||||
// setting opens a link-local address, and Azure's WireServer hands out
|
|
||||||
// VM credentials, so neither refusal points at the setting.
|
|
||||||
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
metadataURLs := map[string]string{
|
|
||||||
"link-local": "http://169.254.169.254/latest/meta-data/",
|
|
||||||
"wireserver": "http://168.63.129.16/?comp=versions",
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, metadataURL := range metadataURLs {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "metadata")
|
|
||||||
form.Set("type", string(database.TargetTypeHTTP))
|
|
||||||
form.Set("url", metadataURL)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodPost,
|
|
||||||
"/hook/"+webhook.ID+"/targets", form,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
assert.NotContains(
|
|
||||||
t, w.Body.String(), privateRefusalHint,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to serialize headers", err)
|
h.serverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to store webhook event", err)
|
h.serverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverError logs an error and answers the sender with a plain-text
|
|
||||||
// 500. The receiver's answers are for programs, so it never sends the
|
|
||||||
// error page the web UI uses.
|
|
||||||
func (h *Handlers) receiverError(
|
|
||||||
w http.ResponseWriter, msg string, err error,
|
|
||||||
) {
|
|
||||||
h.log.Error(msg, "error", err)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventSource carries the fields a new event is built from. The
|
// eventSource carries the fields a new event is built from. The
|
||||||
// receiver fills it from the live request; the resubmit handler fills
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
// it from a stored event. Both then go through createAndFanOut, so an
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
@@ -243,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,
|
||||||
}
|
}
|
||||||
@@ -266,12 +252,11 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target,
|
// createAndFanOut writes the event and one pending delivery per target
|
||||||
// and adds them to the webhook's running totals, in a single
|
// in a single transaction, then hands the tasks to the delivery
|
||||||
// transaction, then hands the tasks to the delivery engine. It is the
|
// engine. It is the only path by which an event and its deliveries are
|
||||||
// only path by which an event and its deliveries are created, so a
|
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
// circuit-broken exactly as a received one is.
|
||||||
// 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.
|
||||||
@@ -311,15 +296,6 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddEventTotals(tx, database.EventTotals{
|
|
||||||
Events: 1, LastEventAt: &event.CreatedAt,
|
|
||||||
})
|
|
||||||
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(
|
||||||
@@ -378,9 +354,8 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliveryTasks creates one pending delivery per target in the
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
// transaction, adds each to its target's totals, and returns the tasks
|
// transaction and returns the tasks for the delivery engine. The
|
||||||
// for the delivery engine. The caller owns the transaction and rolls
|
// caller owns the transaction and rolls it back on error.
|
||||||
// it back on error.
|
|
||||||
func buildDeliveryTasks(
|
func buildDeliveryTasks(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -404,13 +379,6 @@ func buildDeliveryTasks(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: targets[i].ID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
// Lifetime counts every event, delivery and failure the webhook
|
|
||||||
// has had, and WithinRetention those still stored.
|
|
||||||
Lifetime Counts
|
|
||||||
WithinRetention Counts
|
|
||||||
|
|
||||||
// InProgress counts the deliveries still pending or retrying.
|
|
||||||
InProgress int64
|
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil when none
|
|
||||||
// has. Retention does not change it.
|
|
||||||
LastEventAt *time.Time
|
|
||||||
|
|
||||||
Last10Minutes RecentWindow
|
|
||||||
Last24Hours RecentWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counts holds a number of events, of deliveries and of failed
|
|
||||||
// deliveries.
|
|
||||||
type Counts struct {
|
|
||||||
Events int64
|
|
||||||
Deliveries int64
|
|
||||||
Failures int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetFinished is how many of one target's deliveries became
|
|
||||||
// delivered, and how many failed, in a recent window.
|
|
||||||
type TargetFinished struct {
|
|
||||||
TargetID string
|
|
||||||
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 for the events and one per target for the deliveries, 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 := readTotals(db, stats)
|
|
||||||
if err != nil {
|
|
||||||
return 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last10Minutes, err = readRecentWindow(
|
|
||||||
db, now.Add(-shortWindow),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last24Hours, err = readRecentWindow(
|
|
||||||
db, now.Add(-longWindow),
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTotals fills in the lifetime and within-retention figures, and
|
|
||||||
// when the last event arrived, from the running totals: the events'
|
|
||||||
// row, and the targets' rows summed.
|
|
||||||
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
|
||||||
var events database.EventTotals
|
|
||||||
|
|
||||||
err := db.Take(&events).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets []database.TargetTotals
|
|
||||||
|
|
||||||
err = db.Find(&targets).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading target totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Lifetime.Events = events.Events
|
|
||||||
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
|
||||||
stats.LastEventAt = events.LastEventAt
|
|
||||||
|
|
||||||
for _, t := range targets {
|
|
||||||
stats.Lifetime.Deliveries += t.Deliveries
|
|
||||||
stats.Lifetime.Failures += t.Failed
|
|
||||||
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
|
||||||
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
byTarget, err := finishedByTarget(db, since)
|
|
||||||
if err != nil {
|
|
||||||
return w, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range byTarget {
|
|
||||||
w.Delivered += f.Delivered
|
|
||||||
w.Failed += f.Failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishedByTarget counts, for each target, the deliveries that became
|
|
||||||
// delivered and those that failed since the given time, in one query
|
|
||||||
// over just that window of the deliveries' status index. A target with
|
|
||||||
// neither is left out.
|
|
||||||
func finishedByTarget(
|
|
||||||
db *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
var byTarget []TargetFinished
|
|
||||||
|
|
||||||
err := db.Model(&database.Delivery{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&byTarget).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"counting deliveries finished by target: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget, nil
|
|
||||||
}
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"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 an event's delivery to a target.
|
|
||||||
func statsDelivery(
|
|
||||||
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
||||||
) database.Delivery {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var d database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Where(
|
|
||||||
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
||||||
).First(&d).Error)
|
|
||||||
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsFinish settles a delivery as the delivery engine does: its
|
|
||||||
// final status and the time it finished, and one more on its target's
|
|
||||||
// delivered or failed total, in one transaction.
|
|
||||||
func statsFinish(
|
|
||||||
t *testing.T,
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
d database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", d.ID).
|
|
||||||
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func statsTargetTotals(
|
|
||||||
t *testing.T, webhookDB *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
||||||
// database, its newest event, and its two active targets.
|
|
||||||
type statsHistory struct {
|
|
||||||
webhook *database.Webhook
|
|
||||||
webhookDB *gorm.DB
|
|
||||||
newest database.Event
|
|
||||||
first, second string
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedStatsHistory builds the webhook the statistics test checks: 14
|
|
||||||
// days of retention, twelve entrypoints (one inactive) and six targets
|
|
||||||
// (four inactive). Ten events arrive through the receiver, and so each
|
|
||||||
// has a delivery to the two active targets. The oldest event is past
|
|
||||||
// retention, the next 30 hours old, the next six hours old, the other
|
|
||||||
// seven just in. Six deliveries are settled as the delivery engine
|
|
||||||
// would, two of them inside a recent window though their event arrived
|
|
||||||
// before it. The newest event's delivery to the second target is
|
|
||||||
// retrying, the rest are left pending, and a replay adds a pending
|
|
||||||
// delivery to the oldest event. Once retention has removed the oldest
|
|
||||||
// event, every figure in the pane differs from every other.
|
|
||||||
func seedStatsHistory(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
) statsHistory {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
path := statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
for range 10 {
|
|
||||||
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)
|
|
||||||
|
|
||||||
for range 4 {
|
|
||||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
require.NoError(t, db.DB().Model(inactive).
|
|
||||||
Update("active", false).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := receiverRouter(h)
|
|
||||||
|
|
||||||
for range 10 {
|
|
||||||
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, 10)
|
|
||||||
|
|
||||||
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
|
||||||
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*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(-14*24*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
||||||
|
|
||||||
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
|
|
||||||
require.NoError(t, webhookDB.Model(&retrying).
|
|
||||||
Update("status", database.DeliveryStatusRetrying).Error)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther,
|
|
||||||
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
||||||
|
|
||||||
return statsHistory{
|
|
||||||
webhook: wh, webhookDB: webhookDB, newest: newest,
|
|
||||||
first: first.ID, second: second.ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.EventTotals
|
|
||||||
|
|
||||||
err := webhookDB.Take(&totals).Error
|
|
||||||
|
|
||||||
return err == nil && totals.EventsRemoved == 1
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
lc.RequireStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsPane returns the text of the statistics pane in a rendered
|
|
||||||
// webhook page, everything from its heading to the next heading on the
|
|
||||||
// page, with the markup taken out and each run of space made one
|
|
||||||
// space. A table then reads header by header and row by row, each
|
|
||||||
// row's label followed by its figures in column order.
|
|
||||||
func statsPane(t *testing.T, page string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
||||||
require.True(t, found, "the page has no statistics pane")
|
|
||||||
|
|
||||||
pane, _, _ = strings.Cut(pane, "<h2")
|
|
||||||
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
|
||||||
|
|
||||||
return strings.Join(strings.Fields(pane), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
|
||||||
// each target's totals and its deliveries finished in the last 24
|
|
||||||
// hours. The first target has ten deliveries and the replay, the
|
|
||||||
// second ten; the inactive targets have none and so no row.
|
|
||||||
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
first, second := hist.first, hist.second
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
|
|
||||||
second: {TargetID: second, Deliveries: 10, Failed: 2},
|
|
||||||
}, statsTargetTotals(t, hist.webhookDB))
|
|
||||||
|
|
||||||
lastDay, err := handlers.FinishedByTargetForTest(
|
|
||||||
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, []handlers.TargetFinished{
|
|
||||||
{TargetID: first, Delivered: 1, Failed: 1},
|
|
||||||
{TargetID: second, Failed: 2},
|
|
||||||
}, lastDay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
|
||||||
// history seedStatsHistory builds, once retention has removed the
|
|
||||||
// oldest event: each figure after its label, in its column.
|
|
||||||
func assertStatsPaneAfterPrune(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
hist statsHistory,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
||||||
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
||||||
|
|
||||||
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
|
||||||
"Targets 6 (2 active) "+
|
|
||||||
"Deliveries in progress 13 "+
|
|
||||||
"Last event "+lastEvent+" "+
|
|
||||||
"Retention 14 days")
|
|
||||||
assert.Contains(t, pane, "Lifetime Within retention "+
|
|
||||||
"Events 10 9 "+
|
|
||||||
"Deliveries 21 18 "+
|
|
||||||
"Failures 5 4")
|
|
||||||
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
|
||||||
"Events 7 8 "+
|
|
||||||
"Failures 1 3 "+
|
|
||||||
"Failure percentage 50.0% 75.0%")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
||||||
// the statistics pane shows for the history seedStatsHistory builds,
|
|
||||||
// and each target's totals and recent figures, 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)
|
|
||||||
|
|
||||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
||||||
first, second := hist.first, hist.second
|
|
||||||
|
|
||||||
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
||||||
require.NotNil(t, stats)
|
|
||||||
|
|
||||||
assert.Equal(t, 12, stats.Entrypoints)
|
|
||||||
assert.Equal(t, 11, stats.ActiveEntrypoints)
|
|
||||||
assert.Equal(t, 6, stats.Targets)
|
|
||||||
assert.Equal(t, 2, stats.ActiveTargets)
|
|
||||||
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
|
|
||||||
stats.Lifetime)
|
|
||||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
||||||
assert.Equal(t, int64(15), stats.InProgress)
|
|
||||||
require.NotNil(t, stats.LastEventAt)
|
|
||||||
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
||||||
assert.Equal(t, handlers.RecentWindow{
|
|
||||||
Events: 7, Delivered: 1, Failed: 1,
|
|
||||||
}, stats.Last10Minutes)
|
|
||||||
assert.Equal(t, handlers.RecentWindow{
|
|
||||||
Events: 8, Delivered: 1, Failed: 3,
|
|
||||||
}, stats.Last24Hours)
|
|
||||||
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
||||||
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
|
|
||||||
|
|
||||||
assertStatsTargets(t, hist)
|
|
||||||
|
|
||||||
// Retention removes the oldest event with its three deliveries:
|
|
||||||
// the first target's failed one and the pending replay, and the
|
|
||||||
// second target's pending one.
|
|
||||||
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
||||||
|
|
||||||
after := h.WebhookStatsForTest(hist.webhook.ID)
|
|
||||||
require.NotNil(t, after)
|
|
||||||
|
|
||||||
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
|
||||||
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
|
|
||||||
after.WithinRetention)
|
|
||||||
assert.Equal(t, int64(13), after.InProgress)
|
|
||||||
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
|
||||||
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
|
||||||
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {
|
|
||||||
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
|
|
||||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
TargetID: second, Deliveries: 10, Failed: 2,
|
|
||||||
DeliveriesRemoved: 1,
|
|
||||||
},
|
|
||||||
}, statsTargetTotals(t, hist.webhookDB))
|
|
||||||
|
|
||||||
assertStatsPaneAfterPrune(t, h, sess, hist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
|
||||||
// retention has removed every event, the pane still shows when the last
|
|
||||||
// one arrived rather than "none".
|
|
||||||
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(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 := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
path := statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
require.Equal(t, http.StatusOK,
|
|
||||||
postReceiver(t, receiverRouter(h), path))
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
events := listEvents(t, webhookDB)
|
|
||||||
require.Len(t, events, 1)
|
|
||||||
|
|
||||||
arrived := events[0].CreatedAt
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
||||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
||||||
require.Empty(t, listEvents(t, webhookDB))
|
|
||||||
|
|
||||||
stats := h.WebhookStatsForTest(wh.ID)
|
|
||||||
require.NotNil(t, stats)
|
|
||||||
require.NotNil(t, stats.LastEventAt)
|
|
||||||
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane,
|
|
||||||
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
|
||||||
// last event arrived in UTC, as the event list does, when the time was
|
|
||||||
// stored in another zone, as it is on a host whose local time is not
|
|
||||||
// UTC.
|
|
||||||
func TestWebhookStats_LastEventInUTC(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)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
|
||||||
time.FixedZone("EST", -5*60*60))
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
|
||||||
// pane itself, not only the line at the foot of the page, shows the
|
|
||||||
// webhook's retention period, for a finite one and for forever.
|
|
||||||
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
retentionDays int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{30, "30 days"},
|
|
||||||
{database.RetentionForeverDays, "forever"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID,
|
|
||||||
Name: "retention",
|
|
||||||
RetentionDays: tt.retentionDays,
|
|
||||||
}
|
|
||||||
require.NoError(t,
|
|
||||||
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Retention "+tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Last event none")
|
|
||||||
assert.Contains(t, pane, "Failure percentage — —")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-27
@@ -3,18 +3,17 @@
|
|||||||
// deliveries are attempted, how they end, how long they take, how
|
// deliveries are attempted, how they end, how long they take, how
|
||||||
// deep the queues are, and how many circuit breakers are open.
|
// deep the queues are, and how many circuit breakers are open.
|
||||||
//
|
//
|
||||||
// It also builds the registry the authenticated /metrics route
|
// The inbound HTTP metrics come from the go-http-metrics recorder in
|
||||||
// serves. In production, these collectors, the inbound HTTP metrics
|
// internal/middleware and land on prometheus.DefaultRegisterer. These
|
||||||
// recorded in internal/middleware, and the Go runtime and process
|
// collectors register there too, so both surfaces are gathered by the
|
||||||
// collectors all register on that one registry, never on Prometheus's
|
// one promhttp handler mounted on the authenticated /metrics route.
|
||||||
// global default.
|
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -58,31 +57,25 @@ var knownTargetTypes = []database.TargetType{
|
|||||||
database.TargetTypeSlack,
|
database.TargetTypeSlack,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry returns the registry /metrics serves, carrying the Go
|
// defaultSet is the process-wide metric set, registered on the same
|
||||||
// runtime and process collectors that Prometheus's global default
|
// registry the HTTP middleware and the /metrics handler already use.
|
||||||
// registry carries, so the go_* and process_* series stay in the
|
// It is built on first use rather than in an init so that a test
|
||||||
// scrape.
|
// binary that never touches metrics never registers them.
|
||||||
//
|
//
|
||||||
// A registry of its own, rather than the global default, is what lets
|
//nolint:gochecknoglobals // one process-wide registration, by design
|
||||||
// two dependency graphs in one process — two tests, say — each
|
var defaultSet = sync.OnceValue(func() *Set {
|
||||||
// register their collectors without the second registration
|
return New(prometheus.DefaultRegisterer)
|
||||||
// panicking.
|
})
|
||||||
func NewRegistry() *prometheus.Registry {
|
|
||||||
reg := prometheus.NewRegistry()
|
|
||||||
reg.MustRegister(
|
|
||||||
collectors.NewGoCollector(),
|
|
||||||
collectors.NewProcessCollector(
|
|
||||||
collectors.ProcessCollectorOpts{},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return reg
|
// Default returns the process-wide metric set.
|
||||||
|
func Default() *Set {
|
||||||
|
return defaultSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set is one registered group of webhooker's delivery collectors.
|
// Set is one registered group of webhooker's delivery collectors.
|
||||||
// Production builds one on the registry /metrics serves; tests build
|
// Production uses the single Default set; tests build their own
|
||||||
// one on a registry of their own so they can gather what their own
|
// against a private registry so assertions are not disturbed by
|
||||||
// deliveries recorded.
|
// deliveries other tests are making concurrently.
|
||||||
type Set struct {
|
type Set struct {
|
||||||
eventsReceived prometheus.Counter
|
eventsReceived prometheus.Counter
|
||||||
deliveryAttempts *prometheus.CounterVec
|
deliveryAttempts *prometheus.CounterVec
|
||||||
@@ -100,7 +93,7 @@ type Set struct {
|
|||||||
// New registers a full set of delivery collectors on reg and returns
|
// New registers a full set of delivery collectors on reg and returns
|
||||||
// it. It panics if reg already holds them, which is the intended
|
// it. It panics if reg already holds them, which is the intended
|
||||||
// behaviour for a duplicate registration.
|
// behaviour for a duplicate registration.
|
||||||
func New(reg *prometheus.Registry) *Set {
|
func New(reg prometheus.Registerer) *Set {
|
||||||
factory := promauto.With(reg)
|
factory := promauto.With(reg)
|
||||||
|
|
||||||
s := &Set{
|
s := &Set{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token are logged and answered by forbidden, which must write the 403.
|
// token receive a 403 Forbidden response.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol
|
// The middleware detects the client-facing transport protocol
|
||||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
@@ -36,9 +36,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||||
// csrf.Secure option is set at creation time, not per-request.
|
// csrf.Secure option is set at creation time, not per-request.
|
||||||
func (m *Middleware) CSRF(
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||||
forbidden http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// CSRF is registered ahead of RequireAuth on every route
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
forbidden.ServeHTTP(w, r)
|
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
key := m.session.GetKey()
|
key := m.session.GetKey()
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ import (
|
|||||||
// csrfCookieName is the gorilla/csrf cookie name.
|
// csrfCookieName is the gorilla/csrf cookie name.
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
const csrfCookieName = "_gorilla_csrf"
|
||||||
|
|
||||||
// forbidden stands in for the error page the server hands CSRF to
|
|
||||||
// answer a refused request with.
|
|
||||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfGetToken performs a GET request through the CSRF middleware
|
// csrfGetToken performs a GET request through the CSRF middleware
|
||||||
// and returns the token and cookies.
|
// and returns the token and cookies.
|
||||||
func csrfGetToken(
|
func csrfGetToken(
|
||||||
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
|||||||
|
|
||||||
var gotToken string
|
var gotToken string
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
gotToken = middleware.CSRFToken(r)
|
gotToken = middleware.CSRFToken(r)
|
||||||
},
|
},
|
||||||
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
|||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
called = true
|
called = true
|
||||||
},
|
},
|
||||||
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
newReq := func(method string) *http.Request {
|
newReq := func(method string) *http.Request {
|
||||||
r := httptest.NewRequestWithContext(
|
r := httptest.NewRequestWithContext(
|
||||||
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
|
|
||||||
// MetricsMiddlewareForTest builds the metrics recording middleware
|
// MetricsMiddlewareForTest builds the metrics recording middleware
|
||||||
// against a caller-supplied recorder, so a test can gather from its
|
// against a caller-supplied recorder, so a test can gather from its
|
||||||
// own Prometheus registry without building a whole Middleware.
|
// own Prometheus registry rather than the process-wide default one
|
||||||
|
// that Middleware.Metrics uses.
|
||||||
func MetricsMiddlewareForTest(
|
func MetricsMiddlewareForTest(
|
||||||
rec httpmetrics.Recorder,
|
rec httpmetrics.Recorder,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
|
|||||||
) http.Handler {
|
) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
return m.CSRF()(unreachable(t))
|
||||||
unreachable(t),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||||
|
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
)
|
)
|
||||||
@@ -150,17 +151,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
|
|||||||
|
|
||||||
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||||
|
|
||||||
// Metrics returns middleware that records Prometheus HTTP metrics
|
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||||
// with the Middleware's one recorder, which New builds on the registry
|
// the default registry, which is the one the /metrics route gathers.
|
||||||
// the /metrics route serves and NewForTest on a registry of its own.
|
|
||||||
// Every call reuses that recorder, so any number of routers can
|
|
||||||
// install it.
|
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
return metricsMiddleware(s.metricsRecorder)
|
return metricsMiddleware(
|
||||||
|
prommetrics.NewRecorder(prommetrics.Config{}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// metricsMiddleware builds the recording middleware against a given
|
// metricsMiddleware builds the recording middleware against a given
|
||||||
// recorder, so tests can gather from a registry of their own.
|
// recorder, so tests can gather from a registry of their own instead
|
||||||
|
// of the process-wide default.
|
||||||
func metricsMiddleware(
|
func metricsMiddleware(
|
||||||
rec httpmetrics.Recorder,
|
rec httpmetrics.Recorder,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ const (
|
|||||||
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
||||||
// defect, so a test that flattens it would prove nothing.
|
// defect, so a test that flattens it would prove nothing.
|
||||||
//
|
//
|
||||||
// The recorder writes to a registry of the test's own, so each test
|
// The recorder writes to a registry of the test's own rather than the
|
||||||
// observes only its own traffic.
|
// process-wide default one, so each test observes only its own
|
||||||
|
// traffic.
|
||||||
func metricsTestRouter(
|
func metricsTestRouter(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
receiverLimit int,
|
receiverLimit int,
|
||||||
@@ -454,29 +455,3 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
|
|||||||
"the interceptor must still count written bytes",
|
"the interceptor must still count written bytes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
|
|
||||||
// by NewForTest has a recorder of its own: its Metrics() serves a
|
|
||||||
// request instead of panicking, and a second one does not collide
|
|
||||||
// with the first.
|
|
||||||
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
|
||||||
cfg := &config.Config{Environment: "prod"}
|
|
||||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(okBody))
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, okRoute, nil,
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +13,6 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
|
||||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -156,7 +152,6 @@ type MiddlewareParams struct {
|
|||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
Registry *prometheus.Registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||||
@@ -166,14 +161,6 @@ type Middleware struct {
|
|||||||
params *MiddlewareParams
|
params *MiddlewareParams
|
||||||
session *session.Session
|
session *session.Session
|
||||||
|
|
||||||
// metricsRecorder records the inbound HTTP metrics. New builds
|
|
||||||
// it on the registry /metrics serves, NewForTest on a registry
|
|
||||||
// of its own. Either way it is built once per Middleware and
|
|
||||||
// Metrics reuses it, because building it registers its
|
|
||||||
// collectors, and a second registration on the same registry
|
|
||||||
// panics.
|
|
||||||
metricsRecorder httpmetrics.Recorder
|
|
||||||
|
|
||||||
// loginGuard counts failed credential verifications and bounds
|
// loginGuard counts failed credential verifications and bounds
|
||||||
// concurrent password hashing. It is built on first use so that
|
// concurrent password hashing. It is built on first use so that
|
||||||
// every construction path gets one; see guard().
|
// every construction path gets one; see guard().
|
||||||
@@ -192,9 +179,6 @@ func New(
|
|||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
s.session = params.Session
|
s.session = params.Session
|
||||||
s.metricsRecorder = prommetrics.NewRecorder(
|
|
||||||
prommetrics.Config{Registry: params.Registry},
|
|
||||||
)
|
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -382,30 +366,6 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextParam is the query parameter on the login redirect, and the
|
|
||||||
// login form field, that holds the page to return to after login.
|
|
||||||
const NextParam = "next"
|
|
||||||
|
|
||||||
// MaxNextBytes bounds the NextParam value. The login page writes it
|
|
||||||
// into its form, and every page is rendered into a buffer first, so
|
|
||||||
// without a bound a request would choose the size of that buffer.
|
|
||||||
const MaxNextBytes = 2048
|
|
||||||
|
|
||||||
// loginURL is the login page RequireAuth redirects to. A GET carries
|
|
||||||
// its own path and query in NextParam so that logging in returns to
|
|
||||||
// it, unless they are longer than MaxNextBytes; loginDestination in
|
|
||||||
// the handlers package checks whether that value is safe to follow.
|
|
||||||
// Other methods carry nothing, since a redirect cannot repeat them.
|
|
||||||
func loginURL(r *http.Request) string {
|
|
||||||
next := r.URL.RequestURI()
|
|
||||||
|
|
||||||
if r.Method != http.MethodGet || len(next) > MaxNextBytes {
|
|
||||||
return "/pages/login"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/pages/login?" + url.Values{NextParam: {next}}.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireAuth returns middleware that checks for a valid session.
|
// RequireAuth returns middleware that checks for a valid session.
|
||||||
// Unauthenticated users are redirected to the login page.
|
// Unauthenticated users are redirected to the login page.
|
||||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||||
@@ -421,7 +381,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, loginURL(r), http.StatusSeeOther,
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -449,7 +409,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, loginURL(r), http.StatusSeeOther,
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -338,76 +338,6 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
|||||||
"unauthenticated request",
|
"unauthenticated request",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
|
|
||||||
// redirect carries: a GET's path and query, so logging in can return
|
|
||||||
// there, and nothing for a POST, which a redirect cannot repeat.
|
|
||||||
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
||||||
|
|
||||||
handler := m.RequireAuth()(http.HandlerFunc(
|
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {},
|
|
||||||
))
|
|
||||||
|
|
||||||
get := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet, "/hook/abc/events?page=2", nil,
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, get)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2Fhook%2Fabc%2Fevents%3Fpage%3D2",
|
|
||||||
w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
post := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost, "/hook/abc/delete", nil,
|
|
||||||
)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, post)
|
|
||||||
|
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRequireAuth_LoginRedirectLeavesOutALongURL: a GET whose path
|
|
||||||
// and query are longer than the login page accepts goes to the plain
|
|
||||||
// login page, so a long URL does not make the redirect long.
|
|
||||||
func TestRequireAuth_LoginRedirectLeavesOutALongURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
||||||
|
|
||||||
handler := m.RequireAuth()(http.HandlerFunc(
|
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {},
|
|
||||||
))
|
|
||||||
|
|
||||||
atLimit := "/" + strings.Repeat("a", middleware.MaxNextBytes-1)
|
|
||||||
|
|
||||||
get := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, atLimit, nil,
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, get)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2F"+atLimit[1:],
|
|
||||||
w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
get = httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, atLimit+"a", nil,
|
|
||||||
)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, get)
|
|
||||||
|
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,9 +443,7 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
|||||||
"unauthenticated session",
|
"unauthenticated session",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RequireAuth Session Expiry Tests ---
|
// --- RequireAuth Session Expiry Tests ---
|
||||||
@@ -613,9 +541,7 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
|||||||
"handler should not run for an idle-expired session",
|
"handler should not run for an idle-expired session",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
assert.Empty(
|
assert.Empty(
|
||||||
t, sessionCookies(w),
|
t, sessionCookies(w),
|
||||||
"an expired session must not be refreshed",
|
"an expired session must not be refreshed",
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
|
|
||||||
// Recoverer returns middleware that turns a handler panic into one
|
// Recoverer returns middleware that turns a handler panic into one
|
||||||
// structured ERROR record and a 500, rather than a dropped
|
// structured ERROR record and a 500, rather than a dropped
|
||||||
// connection. The 500 is page when page is not nil, and plain text
|
// connection.
|
||||||
// when it is nil or when page panics before writing anything.
|
|
||||||
//
|
//
|
||||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||||
@@ -137,13 +136,9 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
//
|
//
|
||||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||||
// set before panicking, because a request that failed must not hand
|
// set before panicking, because a request that failed must not hand
|
||||||
// the client a credential. It touches no other header: when page
|
// the client a credential; every other header is left to http.Error.
|
||||||
// answers, every other header the handler set goes out with it, apart
|
|
||||||
// from any page sets itself; otherwise they are left to http.Error.
|
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||||
func (s *Middleware) Recoverer(
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
page http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
@@ -176,14 +171,6 @@ func (s *Middleware) Recoverer(
|
|||||||
|
|
||||||
rw.Header().Del("Set-Cookie")
|
rw.Header().Del("Set-Cookie")
|
||||||
|
|
||||||
if page != nil {
|
|
||||||
s.servePage(rw, r, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rw.committed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(
|
http.Error(
|
||||||
rw,
|
rw,
|
||||||
http.StatusText(
|
http.StatusText(
|
||||||
@@ -198,27 +185,6 @@ func (s *Middleware) Recoverer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// servePage answers with page. A panic in page itself is logged and
|
|
||||||
// recovered here, so the Recoverer can still send its plain 500.
|
|
||||||
func (s *Middleware) servePage(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
page http.Handler,
|
|
||||||
) {
|
|
||||||
defer func() {
|
|
||||||
rvr := recover()
|
|
||||||
if rvr != nil {
|
|
||||||
s.log.Error("error page panic",
|
|
||||||
"panic", logfield.Truncate(
|
|
||||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
page.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logPanic writes the record. Every field it can grow is truncated to
|
// logPanic writes the record. Every field it can grow is truncated to
|
||||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
|||||||
// Logging outside so the recovered 500 is the status it records.
|
// Logging outside so the recovered 500 is the status it records.
|
||||||
router.Use(chimw.RequestID)
|
router.Use(chimw.RequestID)
|
||||||
router.Use(m.Logging())
|
router.Use(m.Logging())
|
||||||
router.Use(m.Recoverer(nil))
|
router.Use(m.Recoverer())
|
||||||
router.Get("/probe", handler)
|
router.Get("/probe", handler)
|
||||||
|
|
||||||
serverErrors := new(bytes.Buffer)
|
serverErrors := new(bytes.Buffer)
|
||||||
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
|
|
||||||
m, _ := capturingMiddleware(t)
|
m, _ := capturingMiddleware(t)
|
||||||
|
|
||||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
handler := m.Recoverer()(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("chunk"))
|
_, _ = w.Write([]byte("chunk"))
|
||||||
|
|
||||||
@@ -672,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "chunk", string(body))
|
assert.Equal(t, "chunk", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
|
||||||
// the panic is logged as before, and the 500 is that page.
|
|
||||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte("the error page"))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "the error page", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
|
||||||
// before writing anything: both panics are logged, and the client
|
|
||||||
// still gets the plain 500.
|
|
||||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(http.ResponseWriter, *http.Request) {
|
|
||||||
panic(pagePanic)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
assert.Contains(t, logs.String(), pagePanic)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewForTest creates a Middleware with the minimum dependencies
|
// NewForTest creates a Middleware with the minimum dependencies
|
||||||
// needed for testing. This bypasses the fx lifecycle.
|
// needed for testing. This bypasses the fx lifecycle.
|
||||||
//
|
|
||||||
// Its metrics recorder writes to a fresh registry of its own, so
|
|
||||||
// Metrics() works on it and two of them never collide.
|
|
||||||
func NewForTest(
|
func NewForTest(
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@@ -25,8 +20,5 @@ func NewForTest(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
},
|
},
|
||||||
session: sess,
|
session: sess,
|
||||||
metricsRecorder: prommetrics.NewRecorder(
|
|
||||||
prommetrics.Config{Registry: prometheus.NewRegistry()},
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
@@ -141,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,
|
||||||
@@ -152,10 +151,6 @@ func newServerApp(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -168,8 +163,6 @@ func newServerApp(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The link back the error page offers: to the webhook list for a
|
|
||||||
// signed-in user, to sign-in for anyone else.
|
|
||||||
const (
|
|
||||||
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
|
|
||||||
`Back to webhooks</a>`
|
|
||||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
|
||||||
`Sign in</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// assertErrorPage checks that w is the error page for status, in the
|
|
||||||
// normal layout, offering link.
|
|
||||||
func assertErrorPage(
|
|
||||||
t *testing.T,
|
|
||||||
w *httptest.ResponseRecorder,
|
|
||||||
status int,
|
|
||||||
link string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
assert.Equal(t, status, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
|
||||||
)
|
|
||||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
|
||||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
|
||||||
assert.Contains(
|
|
||||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, link)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
|
||||||
|
|
||||||
w := env.get("/hook/"+wh.ID, cookies)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
|
||||||
|
|
||||||
w := env.get(
|
|
||||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
|
|
||||||
// page that fails is not shown above the error.
|
|
||||||
func TestErrorPage_ShowsNoNotice(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.NotContains(t, w.Body.String(), "Webhook saved.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.get("/no-such-page", nil),
|
|
||||||
http.StatusNotFound, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Outside every route group there is no form token, so the
|
|
||||||
// page leaves out the logout form rather than offer one that
|
|
||||||
// would be refused.
|
|
||||||
w := env.get("/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
|
|
||||||
// Inside a route group the page has a token, and logout works.
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", "someone")
|
|
||||||
form.Set("password", "irrelevant")
|
|
||||||
form.Set("csrf_token", "not-a-token")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/pages/login", form, nil),
|
|
||||||
http.StatusForbidden, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
|
|
||||||
edit := url.Values{}
|
|
||||||
edit.Set("name", "renamed")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
|
|
||||||
http.StatusForbidden, backToWebhooks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
|
||||||
// admin page route group through the real router, with error
|
|
||||||
// tracking on: the client gets the 500 error page, and the tracker
|
|
||||||
// still gets the panic, once. The same panic outside the admin page
|
|
||||||
// route groups keeps the plain 500.
|
|
||||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
transport := &captureTransport{}
|
|
||||||
|
|
||||||
opts := server.SentryClientOptionsForTest(
|
|
||||||
"https://public@sentry.invalid/1", "webhooker-test",
|
|
||||||
)
|
|
||||||
opts.Transport = transport
|
|
||||||
|
|
||||||
client, err := sentry.NewClient(opts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
sentry.SetHubOnContext(
|
|
||||||
context.Background(),
|
|
||||||
sentry.NewHub(client, sentry.NewScope()),
|
|
||||||
),
|
|
||||||
http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
w := serve(
|
|
||||||
server.NewRouterWithPageProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.PageProbePattern,
|
|
||||||
)
|
|
||||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
|
||||||
|
|
||||||
w = serve(
|
|
||||||
server.NewRouterWithProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.ProbePattern,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
|
|
||||||
require.Len(t, transport.events, 2)
|
|
||||||
|
|
||||||
for _, event := range transport.events {
|
|
||||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
|
||||||
// the web UI only: a sender posting to an entrypoint that does not
|
|
||||||
// exist still gets the plain-text answer.
|
|
||||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// newTestEnv leaves the receiver rate limit at zero, which
|
|
||||||
// refuses every request before it reaches the receiver.
|
|
||||||
env := newTestEnvWithConfig(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
ReceiverRateLimit: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
w := env.post(
|
|
||||||
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
|
|||||||
|
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
|
||||||
// probe: inside the /pages route group, the admin page group a
|
|
||||||
// request reaches without signing in.
|
|
||||||
const PageProbePattern = "/pages/probe"
|
|
||||||
|
|
||||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
|
||||||
// probe added to the /pages route group once SetupRoutes has built
|
|
||||||
// it, so the probe runs behind that group's own middleware exactly as
|
|
||||||
// the group's real routes do.
|
|
||||||
func NewRouterWithPageProbeForTest(
|
|
||||||
log *slog.Logger,
|
|
||||||
cfg *config.Config,
|
|
||||||
mw *middleware.Middleware,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sentryEnabled bool,
|
|
||||||
probe http.HandlerFunc,
|
|
||||||
) http.Handler {
|
|
||||||
s := &Server{
|
|
||||||
log: log,
|
|
||||||
mw: mw,
|
|
||||||
h: h,
|
|
||||||
params: ServerParams{Config: cfg},
|
|
||||||
}
|
|
||||||
s.sentryEnabled.Store(sentryEnabled)
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for _, route := range s.router.Routes() {
|
|
||||||
pages, ok := route.SubRoutes.(chi.Router)
|
|
||||||
if ok && route.Pattern == "/pages/*" {
|
|
||||||
pages.Get("/probe", probe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.router
|
|
||||||
}
|
|
||||||
|
|||||||
+25
-71
@@ -7,6 +7,7 @@ import (
|
|||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"sneak.berlin/go/webhooker/static"
|
"sneak.berlin/go/webhooker/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,11 +15,9 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// The five admin page route groups below (/pages, /user/{username},
|
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||||
// /settings, /hooks and /hook/{sourceID}) install
|
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
// orderings are deliberate.
|
||||||
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
|
||||||
// deliberate.
|
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -47,14 +46,6 @@ const requestTimeout = 60 * time.Second
|
|||||||
// server's router.
|
// server's router.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
// An unknown path gets the error page. Registered before the
|
|
||||||
// global middleware, because chi wraps a not-found handler in the
|
|
||||||
// middleware already on its router, which would then run twice.
|
|
||||||
// The route groups below wrap it in their own middleware the same
|
|
||||||
// way; running theirs twice is harmless.
|
|
||||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
|
||||||
|
|
||||||
s.setupGlobalMiddleware()
|
s.setupGlobalMiddleware()
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
}
|
}
|
||||||
@@ -78,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
|
|||||||
// Panic recovery, deliberately here rather than first. It has to
|
// Panic recovery, deliberately here rather than first. It has to
|
||||||
// run inside every middleware that observes the response, so the
|
// run inside every middleware that observes the response, so the
|
||||||
// 500 it writes is the status the access log records and the
|
// 500 it writes is the status the access log records and the
|
||||||
// metrics count, and outside the sentryhttp handler, whose
|
// metrics count, and outside the sentryhttp handler below, whose
|
||||||
// Repanic option needs something further out to catch what it
|
// Repanic option needs something further out to catch what it
|
||||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||||
// until it was measured: on a current Go release it crashes
|
// until it was measured: on a current Go release it crashes
|
||||||
// inside its stack pretty-printer instead of recovering, so the
|
// inside its stack pretty-printer instead of recovering, so the
|
||||||
// connection dropped and the original panic was never reported.
|
// connection dropped and the original panic was never reported.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||||
s.recoverPanics(s.router, nil)
|
s.router.Use(s.mw.Recoverer())
|
||||||
}
|
|
||||||
|
|
||||||
// recoverPanics installs on r the recoverer, answering a panic with
|
|
||||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
|
||||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
|
||||||
// bubble up to the recoverer.
|
|
||||||
//
|
|
||||||
// Each admin page route group installs its own, with the error page,
|
|
||||||
// as its first middleware. A panic there is logged, reported and
|
|
||||||
// answered inside the group and never reaches the global recoverer,
|
|
||||||
// which keeps the plain 500 for every other route.
|
|
||||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
|
||||||
r.Use(s.mw.Recoverer(page))
|
|
||||||
|
|
||||||
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||||
|
// true so panics still bubble up to the Recoverer middleware
|
||||||
|
// registered immediately above.
|
||||||
if s.sentryEnabled.Load() {
|
if s.sentryEnabled.Load() {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
r.Use(sentryHandler.Handle)
|
s.router.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,35 +130,35 @@ func (s *Server) setupRoutes() {
|
|||||||
if s.params.Config.MetricsAuthEnabled() {
|
if s.params.Config.MetricsAuthEnabled() {
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.MetricsAuth())
|
r.Use(s.mw.MetricsAuth())
|
||||||
r.Get("/metrics", s.h.HandleMetrics())
|
r.Get(
|
||||||
|
"/metrics",
|
||||||
|
http.HandlerFunc(
|
||||||
|
promhttp.Handler().ServeHTTP,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
s.setupSettingsRoutes()
|
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupPageRoutes() {
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
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())
|
||||||
|
|
||||||
@@ -187,13 +168,10 @@ func (s *Server) setupPageRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupUserRoutes() {
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
@@ -203,33 +181,12 @@ func (s *Server) setupUserRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
|
||||||
// configuration comes from the environment and nothing here changes
|
|
||||||
// it.
|
|
||||||
func (s *Server) setupSettingsRoutes() {
|
|
||||||
s.router.Route("/settings", func(r chi.Router) {
|
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
||||||
r.Use(s.mw.NoCache())
|
|
||||||
r.Use(s.mw.RequireAuth())
|
|
||||||
r.Get("/", s.h.HandleSettings())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/hooks", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
@@ -238,13 +195,10 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
@@ -48,8 +47,8 @@ type noopNotifier struct{}
|
|||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||||
// dependency. No test here checks what gets evicted, so it records
|
// dependency. These tests never delete a webhook, so there is
|
||||||
// nothing.
|
// nothing to record.
|
||||||
type noopEvictor struct{}
|
type noopEvictor struct{}
|
||||||
|
|
||||||
func (e *noopEvictor) EvictWebhook(string) {}
|
func (e *noopEvictor) EvictWebhook(string) {}
|
||||||
@@ -104,10 +103,6 @@ func newTestEnvWithConfig(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -118,8 +113,6 @@ func newTestEnvWithConfig(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -247,44 +240,6 @@ func (e *testEnv) csrfFrom(
|
|||||||
return token, combined
|
return token, combined
|
||||||
}
|
}
|
||||||
|
|
||||||
// urlFrom renders the page at path and returns the link or form
|
|
||||||
// action that pattern's one group captures, so a test requests the
|
|
||||||
// URL the template emitted rather than one it wrote itself.
|
|
||||||
func (e *testEnv) urlFrom(
|
|
||||||
t *testing.T,
|
|
||||||
path, pattern string,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := e.get(path, cookies)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
match := regexp.MustCompile(pattern).
|
|
||||||
FindStringSubmatch(w.Body.String())
|
|
||||||
require.Len(t, match, 2, "%s should render %s", path, pattern)
|
|
||||||
|
|
||||||
return html.UnescapeString(match[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireNotice requires w to redirect to dest carrying the notice
|
|
||||||
// code, then renders that page and requires it to show text.
|
|
||||||
func (e *testEnv) requireNotice(
|
|
||||||
t *testing.T,
|
|
||||||
w *httptest.ResponseRecorder,
|
|
||||||
dest, code, text string,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
|
|
||||||
|
|
||||||
page := e.get(w.Header().Get("Location"), cookies)
|
|
||||||
require.Equal(t, http.StatusOK, page.Code)
|
|
||||||
assert.Contains(t, page.Body.String(), text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// authCookies forges an authenticated session for the given user.
|
// authCookies forges an authenticated session for the given user.
|
||||||
func (e *testEnv) authCookies(
|
func (e *testEnv) authCookies(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
@@ -725,69 +680,6 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPagesLogin_ReturnsToTheRequestedPage is
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/384: a page opened while
|
|
||||||
// logged out leads to the login page, and logging in from there lands
|
|
||||||
// on that page, query included.
|
|
||||||
func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
username = "operator"
|
|
||||||
password = "correct-horse-battery-staple"
|
|
||||||
)
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
userID, _ := env.seedUser(t, username, password)
|
|
||||||
asked := "/hook/" + env.seedWebhook(t, userID).ID + "/events?page=2"
|
|
||||||
|
|
||||||
bounced := env.get(asked, nil)
|
|
||||||
require.Equal(t, http.StatusSeeOther, bounced.Code)
|
|
||||||
|
|
||||||
loginPage := bounced.Header().Get("Location")
|
|
||||||
|
|
||||||
match := regexp.MustCompile(`name="next" value="([^"]*)"`).
|
|
||||||
FindStringSubmatch(env.get(loginPage, nil).Body.String())
|
|
||||||
require.Len(t, match, 2, "the login form must carry the page")
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, loginPage, nil)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("username", username)
|
|
||||||
form.Set("password", password)
|
|
||||||
form.Set("next", html.UnescapeString(match[1]))
|
|
||||||
|
|
||||||
w := env.post("/pages/login", form, cookies)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
assert.Equal(t, asked, w.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
|
|
||||||
// lands on the sign-in page, which says so.
|
|
||||||
func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "leaver", "somepassword")
|
|
||||||
token, cookies := env.csrfFrom(
|
|
||||||
t, "/hooks", env.authCookies(t, userID, "leaver"),
|
|
||||||
)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
|
|
||||||
w := env.post(
|
|
||||||
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
|
|
||||||
form, cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The sign-in page is requested without the session cookie, which
|
|
||||||
// the logout told the browser to delete.
|
|
||||||
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -849,356 +741,8 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /hooks group ---
|
|
||||||
|
|
||||||
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
|
|
||||||
// production router, follows both of its links to the new-webhook
|
|
||||||
// form, then submits the form to the action and with the token the
|
|
||||||
// page rendered. A mistyped route, link or form action fails here;
|
|
||||||
// the handler tests cannot catch any of them, because they never
|
|
||||||
// route a request.
|
|
||||||
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "lister", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "lister")
|
|
||||||
|
|
||||||
// The list shows its "Create Webhook" link only while it is empty.
|
|
||||||
createLink := env.urlFrom(
|
|
||||||
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
existing := env.seedWebhook(t, userID)
|
|
||||||
|
|
||||||
list := env.get("/hooks", cookies)
|
|
||||||
require.Equal(t, http.StatusOK, list.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, list.Body.String(), `href="/hook/`+existing.ID+`"`,
|
|
||||||
"the list should link the user's webhook",
|
|
||||||
)
|
|
||||||
|
|
||||||
// The "New Webhook" link has an icon between its href and its text.
|
|
||||||
newLink := env.urlFrom(
|
|
||||||
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
|
|
||||||
cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, newLink, cookies)
|
|
||||||
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
|
|
||||||
assert.Equal(
|
|
||||||
t, action,
|
|
||||||
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
|
|
||||||
"both links should open the new-webhook form",
|
|
||||||
)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("name", "created")
|
|
||||||
|
|
||||||
w := env.post(action, form, cookies)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
var created database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
|
||||||
)
|
|
||||||
env.requireNotice(
|
|
||||||
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
|
|
||||||
cookies,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /hook/{sourceID} group ---
|
// --- /hook/{sourceID} group ---
|
||||||
|
|
||||||
// TestHook_EditFormAndDelete follows the webhook page's Edit link to
|
|
||||||
// the edit form and submits it, then deletes the webhook with the
|
|
||||||
// form on its page, every URL and token taken from the rendered
|
|
||||||
// pages.
|
|
||||||
func TestHook_EditFormAndDelete(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "editor", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "editor")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
|
|
||||||
editPage := env.urlFrom(t, page, `href="(/hook/[^/"]+/edit)"`, cookies)
|
|
||||||
token, cookies := env.csrfFrom(t, editPage, cookies)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("name", "renamed")
|
|
||||||
|
|
||||||
w := env.post(
|
|
||||||
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
|
||||||
form, cookies,
|
|
||||||
)
|
|
||||||
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
|
|
||||||
|
|
||||||
var edited database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().First(&edited, "id = ?", wh.ID).Error)
|
|
||||||
assert.Equal(t, "renamed", edited.Name)
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
|
|
||||||
w = env.post(
|
|
||||||
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
|
||||||
form, cookies,
|
|
||||||
)
|
|
||||||
env.requireNotice(
|
|
||||||
t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusNotFound, env.get(page, cookies).Code,
|
|
||||||
"a deleted webhook's page should be gone",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_EntrypointActions adds, deactivates, activates and deletes
|
|
||||||
// an entrypoint with the forms on the webhook page, each submitted to
|
|
||||||
// the action and with the token the page rendered.
|
|
||||||
func TestHook_EntrypointActions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "epuser", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "epuser")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, page, cookies)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
|
|
||||||
// submit posts the webhook page's form whose action pattern
|
|
||||||
// captures, and requires the redirect back to that page with the
|
|
||||||
// notice code, and the page to show text.
|
|
||||||
submit := func(pattern, code, text string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
|
||||||
env.requireNotice(t, w, page, code, text, cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
|
|
||||||
|
|
||||||
submit(`action="(/hook/[^/"]+/entrypoints)"`,
|
|
||||||
"entrypoint-added", "Entrypoint added.")
|
|
||||||
|
|
||||||
var added database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
|
|
||||||
)
|
|
||||||
require.True(t, added.Active)
|
|
||||||
|
|
||||||
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
|
|
||||||
|
|
||||||
var toggled database.Entrypoint
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
env.db.DB().First(&toggled, "id = ?", added.ID).Error,
|
|
||||||
)
|
|
||||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
|
||||||
|
|
||||||
submit(toggle, "entrypoint-activated", "Entrypoint activated.")
|
|
||||||
|
|
||||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
|
|
||||||
"entrypoint-deleted", "Entrypoint deleted.")
|
|
||||||
|
|
||||||
var left int64
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Model(&database.Entrypoint{}).
|
|
||||||
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
|
||||||
assert.Zero(t, left, "the delete should remove the entrypoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_TargetActions adds a target with the form on the webhook
|
|
||||||
// page, follows its Edit link to the target edit form and submits
|
|
||||||
// it, then deactivates, activates and deletes it, every URL and token
|
|
||||||
// taken from the rendered pages.
|
|
||||||
func TestHook_TargetActions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "tgtuser", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "tgtuser")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, page, cookies)
|
|
||||||
|
|
||||||
// submit posts form, with the token, to the action pattern
|
|
||||||
// captures on the page at from, and requires the redirect back to
|
|
||||||
// the webhook page with the notice code, and that page to show
|
|
||||||
// text.
|
|
||||||
submit := func(from, pattern string, form url.Values, code, text string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
|
|
||||||
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
|
||||||
env.requireNotice(t, w, page, code, text, cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
|
|
||||||
|
|
||||||
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
|
||||||
"name": {"added"},
|
|
||||||
"type": {string(database.TargetTypeLog)},
|
|
||||||
}, "target-added", "Target added.")
|
|
||||||
|
|
||||||
editPage := env.urlFrom(
|
|
||||||
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
|
||||||
)
|
|
||||||
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
|
||||||
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
|
|
||||||
|
|
||||||
var edited database.Target
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
env.db.DB().First(&edited, "webhook_id = ?", wh.ID).Error,
|
|
||||||
)
|
|
||||||
assert.Equal(t, "renamed", edited.Name)
|
|
||||||
require.True(t, edited.Active)
|
|
||||||
|
|
||||||
submit(page, toggle, url.Values{},
|
|
||||||
"target-deactivated", "Target deactivated.")
|
|
||||||
|
|
||||||
var toggled database.Target
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
env.db.DB().First(&toggled, "id = ?", edited.ID).Error,
|
|
||||||
)
|
|
||||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
|
||||||
|
|
||||||
submit(page, toggle, url.Values{},
|
|
||||||
"target-activated", "Target activated.")
|
|
||||||
|
|
||||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
|
||||||
url.Values{}, "target-deleted", "Target deleted.")
|
|
||||||
|
|
||||||
var left int64
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Model(&database.Target{}).
|
|
||||||
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
|
||||||
assert.Zero(t, left, "the delete should remove the target")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
|
|
||||||
// Event Log" link, then resubmits a stored event with the form on
|
|
||||||
// that page, submitted to the action and with the token the page
|
|
||||||
// rendered.
|
|
||||||
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "resubmitter")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
|
|
||||||
|
|
||||||
logsPath := env.urlFrom(
|
|
||||||
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
|
|
||||||
w := env.post(
|
|
||||||
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
|
||||||
form, cookies,
|
|
||||||
)
|
|
||||||
env.requireNotice(
|
|
||||||
t, w, logsPath, "resubmit-no-targets",
|
|
||||||
"this source has no active targets", cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var events int64
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
webhookDB.Model(&database.Event{}).Count(&events).Error,
|
|
||||||
)
|
|
||||||
assert.Equal(t, int64(2), events, "the resubmit stores a new event")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
|
||||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
|
||||||
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
|
||||||
// link beside the recent events, and the event log's page links. Each
|
|
||||||
// must point where it should, and that page must render.
|
|
||||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "navigator", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "navigator")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
|
||||||
|
|
||||||
// The event log shows 25 events a page; one more gives it a second
|
|
||||||
// page, so it renders its Next and Previous links.
|
|
||||||
for range 26 {
|
|
||||||
env.seedEvent(t, wh.ID, "paged")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := "/hooks"
|
|
||||||
newForm := list + "/new"
|
|
||||||
page := "/hook/" + wh.ID
|
|
||||||
targetEdit := page + "/targets/" + tgt.ID + "/edit"
|
|
||||||
events := page + "/events"
|
|
||||||
back := `href="([^"]+)"[^>]*>← Back to `
|
|
||||||
cancel := `href="([^"]+)"[^>]*>Cancel<`
|
|
||||||
|
|
||||||
for _, link := range []struct{ from, pattern, want string }{
|
|
||||||
// The navbar on the profile page: its desktop link, then its
|
|
||||||
// mobile menu link.
|
|
||||||
{
|
|
||||||
"/user/navigator/",
|
|
||||||
`href="([^"]+)" class="btn-text">Webhooks<`,
|
|
||||||
list,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"/user/navigator/",
|
|
||||||
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
|
|
||||||
list,
|
|
||||||
},
|
|
||||||
{list, `href="(/hook/[^"]+)"`, page},
|
|
||||||
{newForm, back, list},
|
|
||||||
{newForm, cancel, list},
|
|
||||||
{page, back, list},
|
|
||||||
{page, `Recent Events</h2>\s*<a href="([^"]+)"`, events},
|
|
||||||
{page + "/edit", back, page},
|
|
||||||
{page + "/edit", cancel, page},
|
|
||||||
{targetEdit, back, page},
|
|
||||||
{targetEdit, cancel, page},
|
|
||||||
{events, back, page},
|
|
||||||
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
|
||||||
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
|
||||||
} {
|
|
||||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
|
||||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
|
||||||
assert.Equal(t, http.StatusOK, env.get(got, cookies).Code, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
// feature the way a user does: render the event log page through
|
// feature the way a user does: render the event log page through
|
||||||
// the production router, take the download URL out of the markup
|
// the production router, take the download URL out of the markup
|
||||||
@@ -1286,10 +830,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|||||||
|
|
||||||
anon := env.get(path, nil)
|
anon := env.get(path, nil)
|
||||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
||||||
t, "/pages/login?next="+url.QueryEscape(path),
|
|
||||||
anon.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
||||||
@@ -1360,8 +901,10 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
html.UnescapeString(action[1]), form, cookies,
|
html.UnescapeString(action[1]), form, cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
env.requireNotice(
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
|
assert.Equal(
|
||||||
|
t, logsPath+"?replay=queued",
|
||||||
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(2), env.countDeliveries(t, wh.ID),
|
t, int64(2), env.countDeliveries(t, wh.ID),
|
||||||
@@ -1537,46 +1080,3 @@ func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoMetricsRoutersInOneProcess pins
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
|
|
||||||
// metrics-enabled router in one process used to panic, because the
|
|
||||||
// HTTP metrics registered on Prometheus's global default registry.
|
|
||||||
// Two routers are built over separate dependency graphs and a third
|
|
||||||
// over the first graph again, and each must still serve the HTTP,
|
|
||||||
// delivery, Go runtime and process series, and the series counting
|
|
||||||
// scrapes of /metrics itself.
|
|
||||||
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
first := newTestEnvWithConfig(
|
|
||||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
||||||
)
|
|
||||||
second := newTestEnvWithConfig(
|
|
||||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
||||||
)
|
|
||||||
third := &testEnv{
|
|
||||||
router: server.NewRouterForTest(
|
|
||||||
first.log.Get(), first.cfg, first.mw, first.hnd,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, env := range []*testEnv{first, second, third} {
|
|
||||||
env.get("/", nil)
|
|
||||||
|
|
||||||
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
||||||
require.Equal(t, http.StatusOK, scrape.Code)
|
|
||||||
|
|
||||||
for _, series := range []string{
|
|
||||||
"http_request_duration_seconds",
|
|
||||||
"http_response_size_bytes",
|
|
||||||
"http_requests_inflight",
|
|
||||||
"webhooker_events_received_total",
|
|
||||||
"go_goroutines",
|
|
||||||
"process_start_time_seconds",
|
|
||||||
"promhttp_metric_handler_requests_total",
|
|
||||||
} {
|
|
||||||
assert.Contains(t, scrape.Body.String(), series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
w := env.get("/settings", nil)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
|
||||||
}
|
|
||||||
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
|||||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit VERSION, such as the Dockerfile's build arg, wins over
|
// The Docker build has no git metadata, so the version arrives as an
|
||||||
// anything derivable.
|
// environment override. It wins over anything derivable.
|
||||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty VERSION is treated as unset rather than stamping an empty
|
// An empty VERSION is treated as unset rather than stamping an empty
|
||||||
// string: a caller exporting VERSION= must not produce a binary
|
// string: the Dockerfile's build arg has a non-empty default, but a
|
||||||
// reporting "".
|
// caller exporting VERSION= must not produce a binary reporting "".
|
||||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A caller can define VERSION as the empty string -- `make build
|
// A caller can define VERSION as the empty string -- `make build
|
||||||
// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
|
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
||||||
// VERSION build arg was given. script/version's own guard does not cover
|
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
||||||
// that: the value never passes through the script. Stamping "" would
|
// that: the value never passes through the script. Stamping "" would
|
||||||
// leave the binary reporting no version and the footer on "dev", which
|
// leave the binary reporting no version and the footer on "dev", which
|
||||||
// is the defect this package exists for.
|
// is the defect this package exists for.
|
||||||
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, dockerfile, "go build",
|
require.NotContains(t, dockerfile, "go build",
|
||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION")
|
require.Contains(t, dockerfile, "ARG VERSION=")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile,
|
||||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname.
|
# The tag comes from script/projectname.
|
||||||
#
|
#
|
||||||
# The version script/version resolves here goes in as the VERSION build
|
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
||||||
# arg, which takes precedence over what the build would derive from the
|
# version itself. It is resolved here, where the checkout is, and passed
|
||||||
# .git in its context.
|
# in as a build arg; without it the image would stamp itself "unknown".
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+1
-11
@@ -22,16 +22,6 @@
|
|||||||
# 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.
|
|
||||||
#
|
|
||||||
# No -v: the Docker build cuts each step's log off at 2 MiB, and verbose output
|
|
||||||
# from the whole suite passes that before a failure is printed. Without it, go
|
|
||||||
# test prints one result line per package and, for a package that fails,
|
|
||||||
# everything its tests wrote, application log lines included.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -39,7 +29,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
"$ROOT/script/assets"
|
||||||
go test -race -p 4 -parallel 8 -timeout 90s ./...
|
go test -v -race -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+7
-5
@@ -7,16 +7,18 @@
|
|||||||
#
|
#
|
||||||
# Order of precedence:
|
# Order of precedence:
|
||||||
#
|
#
|
||||||
# 1. $VERSION, if set and non-empty: an explicit value, such as the
|
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
||||||
# Dockerfile's VERSION build arg.
|
# build that cannot derive it: .dockerignore excludes .git/, so the
|
||||||
|
# builder stage has no git metadata and the Dockerfile takes the
|
||||||
|
# value as a build arg instead.
|
||||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||||
# a clean tagged commit that is exactly the tag; otherwise it
|
# a clean tagged commit that is exactly the tag; otherwise it
|
||||||
# carries the short SHA, the commit distance when a tag is
|
# carries the short SHA, the commit distance when a tag is
|
||||||
# reachable, and a -dirty suffix for uncommitted changes.
|
# reachable, and a -dirty suffix for uncommitted changes.
|
||||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||||
# source tarball, or a `docker build` with no .git in its context
|
# source tarball, or `docker build .` with no --build-arg. That
|
||||||
# and no VERSION build arg. That case must not fail the build and
|
# case must not fail the build and must not name a tag the tree may
|
||||||
# must not name a tag the tree may not be at, so it names nothing.
|
# not be at, so it names nothing.
|
||||||
#
|
#
|
||||||
# The git step insists the enclosing repository is this checkout, not
|
# The git step insists the enclosing repository is this checkout, not
|
||||||
# merely some repository above it: an unpacked tarball sitting inside an
|
# merely some repository above it: an unpacked tarball sitting inside an
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
{{template "notice" .}}
|
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
|
||||||
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
|
||||||
{{if .User}}
|
|
||||||
<a href="/hooks" class="btn-secondary">Back to webhooks</a>
|
|
||||||
{{else}}
|
|
||||||
<a href="/pages/login" class="btn-primary">Sign in</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
|
|
||||||
<form method="POST" action="/pages/login" class="space-y-6">
|
<form method="POST" action="/pages/login" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="next" value="{{.Next}}">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+4
-10
@@ -6,20 +6,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
{{if .User}}
|
|
||||||
<button @click="open = !open" 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="!open" 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>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text">Settings</a>
|
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -27,14 +24,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
{{/* An error page can be served before a form token is issued,
|
|
||||||
and a logout without one is refused. */}}
|
|
||||||
{{if .CSRFToken}}
|
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{else}}
|
||||||
|
<a href="/pages/login" class="btn-primary">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,14 +39,13 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
{{if .CSRFToken}}
|
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{else}}
|
||||||
|
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{{define "notice"}}
|
|
||||||
{{with .Notice}}
|
|
||||||
<div class="max-w-6xl mx-auto px-6 pt-4">
|
|
||||||
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}Settings - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="divide-y divide-gray-100">
|
|
||||||
{{range .Settings}}
|
|
||||||
<div class="p-4">
|
|
||||||
<!-- A value too wide to sit beside its name moves to the
|
|
||||||
next line, where a list breaks only at the spaces
|
|
||||||
between its entries. overflow-wrap: anywhere breaks
|
|
||||||
inside a value only when it alone is wider than the
|
|
||||||
line; an inline style, because the committed
|
|
||||||
tailwind.css has no class for it. -->
|
|
||||||
<div class="flex flex-wrap justify-between items-start gap-4">
|
|
||||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
|
||||||
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
event log, the navbar and the footer, so an entrypoint URL fits on
|
|
||||||
one line. An inline style, because the committed tailwind.css has
|
|
||||||
no class this wide. -->
|
|
||||||
<div class="mx-auto px-6 py-8" style="max-width: 108rem" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||||
{{if .Webhook.Description}}
|
{{if .Webhook.Description}}
|
||||||
@@ -28,8 +24,6 @@
|
|||||||
</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">
|
<div class="card">
|
||||||
@@ -187,30 +181,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="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</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}}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .ReplayMessage}}
|
||||||
|
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ResubmitMessage}}
|
||||||
|
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
{{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}}{{.UTC.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>
|
|
||||||
<table class="w-full text-center text-gray-900">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
|
||||||
<th></th>
|
|
||||||
<th class="py-2 font-medium">Lifetime</th>
|
|
||||||
<th class="py-2 font-medium">Within retention</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Events</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Events}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Events}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Failures}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<table class="w-full text-center text-gray-900">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
|
||||||
<th></th>
|
|
||||||
<th class="py-2 font-medium">Last 10 minutes</th>
|
|
||||||
<th class="py-2 font-medium">Last 24 hours</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Events</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Events}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<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